diff --git a/src/client/batch.rs b/src/client/batch.rs index 2a02dc3..80ab118 100644 --- a/src/client/batch.rs +++ b/src/client/batch.rs @@ -1,191 +1,106 @@ -use serde::Deserialize; - -use std::fs::File; - -use super::*; - +use csv; use rust_decimal::Decimal; -use rust_decimal_macros::dec; +use std::path::Path; +use std::str::FromStr; -#[derive(Debug, Deserialize)] -pub struct PaymentInput { - pub username: String, - pub usd: Decimal, - pub memo: Option, -} +use crate::client::GaloyClient; +use crate::client::Wallet; -impl From for Payment { - fn from(input: PaymentInput) -> Payment { - Payment { - username: input.username, - usd: input.usd, - sats: None, - wallet_id: None, - memo: input.memo, - } +// Utility function to check if file exists +pub fn check_file_exists(file: &str) -> anyhow::Result<()> { + let file_path = Path::new(&file); + if !file_path.exists() { + return Err(anyhow::anyhow!("File not found: {}", file)); } + Ok(()) } -#[derive(Debug)] -struct Payment { - username: String, - usd: Decimal, - sats: Option, - wallet_id: Option, - memo: Option, -} - -pub struct Batch { - payments: Vec, - client: GaloyClient, - /// price in btc/usd - price: Decimal, -} - -impl Batch { - pub fn new(client: GaloyClient, price: Decimal) -> Self { - let payments: Vec = vec![]; - Self { - payments, - client, - price, - } +// Utility function to read and validate the CSV file +pub fn validate_csv( + galoy_cli: &GaloyClient, + file: &str, +) -> anyhow::Result<(Vec, Wallet)> { + let mut reader = csv::ReaderBuilder::new().delimiter(b',').from_path(file)?; + let headers = reader.headers()?.clone(); + + if &headers[0] != "username" + || (&headers[1] != "cents" && &headers[1] != "sats") + || (headers.len() == 3 && &headers[2] != "memo") + { + return Err(anyhow::anyhow!( + "CSV format not correct, requires: username, (cents or sats), memo(optional)" + )); } - pub fn add(&mut self, input: PaymentInput) { - self.payments.push(input.into()); - } - - pub fn add_csv(&mut self, filename: String) -> anyhow::Result<()> { - let file = File::open(filename)?; - let mut rdr = csv::Reader::from_reader(file); - for result in rdr.deserialize() { - let record: PaymentInput = result?; - self.add(record); - } - - Ok(()) - } - - pub fn len(&self) -> usize { - self.payments.len() - } - - pub fn is_empty(&self) -> bool { - self.payments.is_empty() - } + let wallet_type = if headers.get(1) == Some("cents") { + Wallet::Usd + } else { + Wallet::Btc + }; - pub fn populate_wallet_id(&mut self) -> anyhow::Result<()> { - for payment in self.payments.iter_mut() { - let username = payment.username.clone(); - let query = &self.client.default_wallet(username); - match query { - Ok(value) => payment.wallet_id = Some(value.clone()), - Err(error) => bail!("error query {:?}", error), - } - } + let records: Vec = reader.records().collect::>()?; - Ok(()) - } + // Validate each record + for record in &records { + let username = record + .get(0) + .ok_or(anyhow::anyhow!("Username is missing"))?; - pub fn populate_sats(&mut self) -> anyhow::Result<()> { - for payment in self.payments.iter_mut() { - let payment_btc: Decimal = payment.usd / self.price; - payment.sats = Some(payment_btc * dec!(100_000_000)); - } + let amount = record.get(1).ok_or(anyhow::anyhow!("Amount is missing"))?; + amount + .parse::() + .map_err(|_| anyhow::anyhow!("Amount must be a number"))?; - Ok(()) + // Check if the username exists + galoy_cli.default_wallet(username.to_string())?; } - pub fn check_self_payment(&self) -> anyhow::Result<()> { - let me = self.client.me()?; - - #[allow(deprecated)] - let me_username = match me.username { - Some(value) => value, - None => bail!("no username has been set"), - }; - - for payment in self.payments.iter() { - if me_username == payment.username { - println!("{:#?}", (me_username, &payment.username)); - bail!("can't pay to self") - } - } + Ok((records, wallet_type)) +} - Ok(()) +pub fn check_sufficient_balance( + records: &[csv::StringRecord], + wallet_type: Wallet, + galoy_cli: &GaloyClient, +) -> anyhow::Result<()> { + let balance_info = galoy_cli.fetch_balance(Some(wallet_type), Vec::new())?; + let current_balance: Decimal = balance_info.iter().map(|info| info.balance).sum(); + + let mut total_payment_amount: Decimal = Decimal::new(0, 0); + for record in records { + let amount: Decimal = Decimal::from_str(record.get(1).unwrap_or_default())?; + total_payment_amount += amount; } - pub fn check_limit(&self) -> anyhow::Result<()> { - todo!("Check limit. need API on the backend for it"); + if total_payment_amount > current_balance { + return Err(anyhow::anyhow!("Insufficient balance in the wallet")); } - pub fn check_balance(&self) -> anyhow::Result<()> { - let me = self.client.me()?; - let me_wallet_id = me.default_account.default_wallet_id; - - let mut total_sats = dec!(0); + Ok(()) +} - for payment in self.payments.iter() { - let sats = match payment.sats { - Some(value) => value, - None => bail!("sats needs to be populated first"), - }; - total_sats += sats; - } +pub fn execute_batch_payment( + records: &[csv::StringRecord], + wallet_type: Wallet, + galoy_cli: &GaloyClient, +) -> anyhow::Result<()> { + for record in records { + let username = record + .get(0) + .ok_or(anyhow::anyhow!("Username is missing"))?; - let me_default_wallet = me - .default_account - .wallets - .iter() - .find(|wallet| wallet.id == me_wallet_id); - - let balance_sats = match me_default_wallet { - Some(value) => value.balance, - None => bail!("no balance"), - }; - if total_sats > balance_sats { - bail!( - "not enough balance, got {}, need {}", - balance_sats, - total_sats - ) - } + let amount: Decimal = Decimal::from_str(record.get(1).unwrap_or_default())?; - Ok(()) - } + let memo = record.get(2).map(|s| s.to_string()); - pub fn show(&self) { - println!("{:#?}", &self.payments) - } - - pub fn execute(&mut self) -> anyhow::Result<()> { - self.check_self_payment()?; - self.check_balance()?; - - for Payment { - username, - memo, - usd, - sats, - .. - } in self.payments.drain(..) - { - let amount = match sats { - Some(value) => value, - None => bail!("need sats amount"), - }; - let res = &self - .client - .intraleger_send(username.clone(), amount, memo) - .context("issue sending intraledger")?; - - println!( - "payment to {username} of sats {amount}, usd {usd}: {:?}", - res - ); + match wallet_type { + Wallet::Usd => { + galoy_cli.intraleger_usd_send(username.to_string(), amount, memo)?; + } + Wallet::Btc => { + galoy_cli.intraleger_send(username.to_string(), amount, memo)?; + } } - - Ok(()) } + Ok(()) } diff --git a/src/client/mod.rs b/src/client/mod.rs index 51f5297..87c9bd9 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -4,7 +4,7 @@ use reqwest::blocking::Client; use log::info; use rust_decimal::Decimal; -use std::net::TcpListener; +use std::{collections::HashSet, net::TcpListener}; pub mod queries; pub use queries::*; @@ -13,12 +13,24 @@ pub mod error; pub use error::*; pub mod batch; -pub use batch::Batch; +use crate::client::batch::*; -use self::query_me::WalletCurrency; +pub use self::query_me::WalletCurrency; + +use crate::types::*; pub mod server; +impl From<&WalletCurrency> for Wallet { + fn from(currency: &WalletCurrency) -> Self { + match currency { + WalletCurrency::USD => Wallet::Usd, + WalletCurrency::BTC => Wallet::Btc, + _ => panic!("Unsupported currency"), + } + } +} + pub struct GaloyClient { graphql_client: Client, api: String, @@ -97,6 +109,46 @@ impl GaloyClient { Ok(me) } + pub fn fetch_balance( + &self, + wallet_type: Option, + wallet_ids: Vec, + ) -> anyhow::Result> { + let me = self.me()?; + let default_wallet_id = me.default_account.default_wallet_id; + let wallets = &me.default_account.wallets; + + let wallet_ids_set: HashSet<_> = wallet_ids.into_iter().collect(); + + let balances: Vec<_> = wallets + .iter() + .filter(|wallet_info| { + wallet_ids_set.contains(&wallet_info.id) + || wallet_type.as_ref().map_or(wallet_ids_set.is_empty(), |w| { + *w == Wallet::from(&wallet_info.wallet_currency) + }) + }) + .map(|wallet_info| WalletBalance { + currency: format!("{:?}", Wallet::from(&wallet_info.wallet_currency)), + balance: wallet_info.balance, + id: if wallet_info.wallet_currency == WalletCurrency::USD + || wallet_info.wallet_currency == WalletCurrency::BTC + { + None + } else { + Some(wallet_info.id.clone()) + }, + default: wallet_info.id == default_wallet_id, + }) + .collect(); + + if balances.is_empty() { + Err(anyhow::anyhow!("No matching wallet found")) + } else { + Ok(balances) + } + } + pub fn request_phone_code(&self, phone: String, nocaptcha: bool) -> std::io::Result<()> { match nocaptcha { false => { @@ -286,26 +338,12 @@ impl GaloyClient { Ok(()) } - // TODO: check if we can do self without & - pub fn batch(self, filename: String, price: Decimal) -> anyhow::Result<()> { - let mut batch = Batch::new(self, price); - - batch.add_csv(filename).context("can't load file")?; - - batch - .populate_wallet_id() - .context("cant get wallet id for all username")?; - - batch - .populate_sats() - .context("cant set sats all payments")?; - - println!("going to execute:"); - batch.show(); - - batch.execute().context("can't make payment successfully")?; - - Ok(()) + pub fn batch_payment(self, file: String) -> anyhow::Result { + check_file_exists(&file)?; + let (reader, wallet_type) = validate_csv(&self, &file)?; + check_sufficient_balance(&reader, wallet_type.clone(), &self)?; + execute_batch_payment(&reader, wallet_type, &self)?; + Ok("Batch Payment Successful".to_string()) } pub fn create_captcha_challenge(&self) -> Result { diff --git a/src/client/queries.rs b/src/client/queries.rs index 0400205..a3463be 100644 --- a/src/client/queries.rs +++ b/src/client/queries.rs @@ -41,7 +41,7 @@ pub use self::query_globals::QueryGlobalsGlobals; query_path = "src/client/graphql/queries/me.graphql", response_derives = "Debug, Serialize, PartialEq" )] -pub(super) struct QueryMe; +pub struct QueryMe; pub use self::query_me::QueryMeMe; // mutations diff --git a/src/lib.rs b/src/lib.rs index 610f485..1b404bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,5 @@ mod client; pub use client::*; + +pub mod types; diff --git a/src/main.rs b/src/main.rs index 1e1b49d..2fd5672 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,8 @@ use std::fs::{self}; mod constants; mod token; +use galoy_cli::types::*; + #[derive(Parser)] #[clap(author, version, about, long_about = None)] struct Cli { @@ -65,6 +67,15 @@ enum Commands { }, /// Execute Me query Me, + /// Fetch the balance of a wallet + Balance { + #[clap(long)] + btc: bool, + #[clap(long)] + usd: bool, + #[clap(long, use_value_delimiter = true)] + wallet_ids: Vec, + }, /// Execute a Payment Pay { #[clap(short, long)] @@ -79,13 +90,10 @@ enum Commands { memo: Option, }, /// execute a batch payment - Batch { filename: String, price: Decimal }, -} - -#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)] -enum Wallet { - Btc, - Usd, + Batch { + #[clap(short, long = "csv")] + file: String, + }, } fn main() -> anyhow::Result<()> { @@ -128,6 +136,24 @@ fn main() -> anyhow::Result<()> { serde_json::to_string_pretty(&result).expect("Can't serialize json") ); } + Commands::Balance { + btc, + usd, + wallet_ids, + } => { + let wallet_type = match (btc, usd) { + (true, true) | (false, false) => None, + (true, false) => Some(Wallet::Btc), + (false, true) => Some(Wallet::Usd), + }; + + let balances = galoy_cli + .fetch_balance(wallet_type, wallet_ids) + .context("can't fetch balance")?; + let balances_json = + serde_json::to_string_pretty(&balances).context("Can't serialize json")?; + println!("{}", balances_json); + } Commands::Pay { username, wallet, @@ -180,9 +206,9 @@ fn main() -> anyhow::Result<()> { Ok(_) => println!("Username has been successfully set!"), Err(err) => println!("Error occurred while setting username: {}", err), }, - Commands::Batch { filename, price } => { + Commands::Batch { file } => { let result = galoy_cli - .batch(filename, price) + .batch_payment(file) .context("issue batching payment"); println!("{:#?}", result); } diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..065d01d --- /dev/null +++ b/src/types.rs @@ -0,0 +1,16 @@ +use rust_decimal::Decimal; +use serde::Serialize; + +#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)] +pub enum Wallet { + Btc, + Usd, +} + +#[derive(Debug, Serialize)] +pub struct WalletBalance { + pub currency: String, + pub balance: Decimal, + pub id: Option, + pub default: bool, +} diff --git a/tests/batch.rs b/tests/batch.rs deleted file mode 100644 index 115cd8e..0000000 --- a/tests/batch.rs +++ /dev/null @@ -1,92 +0,0 @@ -use galoy_cli::batch::Batch; -use galoy_cli::GaloyClient; - -use galoy_cli::batch::PaymentInput; -use rust_decimal_macros::dec; - -mod common; - -#[test] -#[ignore] -fn batch_csv() { - let filename = "./tests/fixtures/example.csv".to_string(); - - let galoy_cli = common::unauth_client(); - - let mut batch = Batch::new(galoy_cli, dec!(10_000)); - - batch.add_csv(filename).unwrap(); - assert_eq!(batch.len(), 2); - - assert!(batch.populate_wallet_id().is_ok()); - assert!(batch.populate_sats().is_ok()); - - batch.show(); -} - -#[test] -#[ignore] -fn batch_cant_pay_self() { - let galoy_cli = common::auth_client(); - - let mut batch = Batch::new(galoy_cli, dec!(10_000)); - - batch.add(PaymentInput { - username: "userA".to_string(), - usd: dec!(10), - memo: None, - }); - - assert!(batch.populate_wallet_id().is_ok()); - assert!(batch.populate_sats().is_ok()); - assert!(batch.check_balance().is_ok()); - assert!(batch.check_self_payment().is_err()); -} - -#[test] -#[ignore] -fn batch_balance_too_low() { - let galoy_cli = common::auth_client(); - - let mut batch = Batch::new(galoy_cli, dec!(10_000)); - - batch.add(PaymentInput { - username: "userB".to_string(), - usd: dec!(1_000_000_000), - memo: None, - }); - - assert!(batch.populate_wallet_id().is_ok()); - assert!(batch.populate_sats().is_ok()); - assert!(batch.check_balance().is_err()); - assert!(batch.check_self_payment().is_ok()); -} - -#[test] -#[ignore] -fn execute_batch() { - let galoy_cli = common::auth_client(); - - let mut batch = Batch::new(galoy_cli, dec!(10_000)); - - batch.add(PaymentInput { - username: "userB".to_string(), - usd: dec!(2), - memo: None, - }); - batch.add(PaymentInput { - username: "userB".to_string(), - usd: dec!(5), - memo: Some("memo for second batch tx".to_string()), - }); - - assert!(batch.populate_wallet_id().is_ok()); - assert!(batch.populate_sats().is_ok()); - assert!(batch.check_balance().is_ok()); - assert!(batch.check_self_payment().is_ok()); - - let result = batch.execute().expect("didn't complete batch successfully"); - println!("{:?}", result); - - // TODO: check balance and transactions -}