diff --git a/Cargo.lock b/Cargo.lock index d303f94..ae2103e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,28 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -144,12 +166,13 @@ checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be" [[package]] name = "bourso-cli" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "bourso_api", "clap", "directories", + "futures-util", "log", "log4rs", "rpassword", @@ -160,12 +183,14 @@ dependencies = [ [[package]] name = "bourso_api" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", + "async-stream", "chrono", "clap", "cookie_store", + "futures-util", "lazy_static", "log", "regex", @@ -456,6 +481,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -475,9 +511,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 56b0774..7368bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bourso-cli" -version = "0.3.1" +version = "0.3.2" edition = "2021" repository = "https://github.com/azerpas/bourso-api" @@ -17,6 +17,7 @@ serde = { version = "1.0.189", features = ["derive"] } serde_json = { version = "1.0.107" } log = { version = "0.4.20" } log4rs = { version = "1.3.0" } +futures-util = "0.3.31" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/src/bourso_api/Cargo.toml b/src/bourso_api/Cargo.toml index 5b5637a..314b113 100644 --- a/src/bourso_api/Cargo.toml +++ b/src/bourso_api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bourso_api" -version = "0.3.0" +version = "0.4.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -17,6 +17,8 @@ reqwest_cookie_store = "0.8.0" cookie_store = "0.21.1" chrono = { version = "0.4.39" } log = { version = "0.4.20" } +futures-util = "0.3.31" +async-stream = "0.3.6" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/src/bourso_api/src/client/transfer/error.rs b/src/bourso_api/src/client/transfer/error.rs index 465d508..a4b1de6 100644 --- a/src/bourso_api/src/client/transfer/error.rs +++ b/src/bourso_api/src/client/transfer/error.rs @@ -35,3 +35,5 @@ impl fmt::Display for TransferError { } } } + +impl std::error::Error for TransferError {} diff --git a/src/bourso_api/src/client/transfer/mod.rs b/src/bourso_api/src/client/transfer/mod.rs index cd38c94..e38c05b 100644 --- a/src/bourso_api/src/client/transfer/mod.rs +++ b/src/bourso_api/src/client/transfer/mod.rs @@ -2,47 +2,66 @@ use crate::account::{Account, AccountKind}; use crate::{client::transfer::error::TransferError, client::BoursoWebClient, constants::BASE_URL}; use anyhow::{bail, Context, Result}; +use futures_util::stream::Stream; mod error; -impl BoursoWebClient { - #[cfg(not(tarpaulin_include))] - pub async fn transfer_funds( - &self, - amount: f64, - from_account: Account, - to_account: Account, - reason: Option<&str>, - ) -> Result<()> { - // Minimum amount is 10 EUR +#[derive(Debug, Clone)] +pub enum TransferProgress { + Validating, + InitializingTransfer, + ExtractingFlowInstance, + SettingDebitAccount, + SettingCreditAccount, + SettingAmount, + SubmittingStep7, + SettingReason, + ConfirmingTransfer, + Completed, +} - if amount < 10.0 { - bail!(TransferError::AmountTooLow); +impl TransferProgress { + #[cfg(not(tarpaulin_include))] + pub fn step_number(&self) -> u8 { + match self { + TransferProgress::Validating => 1, + TransferProgress::InitializingTransfer => 2, + TransferProgress::ExtractingFlowInstance => 3, + TransferProgress::SettingDebitAccount => 4, + TransferProgress::SettingCreditAccount => 5, + TransferProgress::SettingAmount => 6, + TransferProgress::SubmittingStep7 => 7, + TransferProgress::SettingReason => 8, + TransferProgress::ConfirmingTransfer => 9, + TransferProgress::Completed => 10, } + } - log::debug!( - "Initiating transfer of {:.2} EUR from account {} to account {}", - amount, - from_account.id, - to_account.id - ); - - let transfer_from_banking = from_account.kind == AccountKind::Banking; - - let from_account = &from_account.id; - let to_account = &to_account.id; + pub fn total_steps() -> u8 { + 10 + } - // Default reason if none provided, else use provided reason and - // warn if the reason is too long (> 50 characters) - let transfer_reason = if let Some(r) = reason { - if r.len() > 50 { - bail!(TransferError::ReasonIsTooLong); - } - r.to_string() - } else { - "Virement depuis BoursoBank".to_string() - }; + #[cfg(not(tarpaulin_include))] + pub fn description(&self) -> &str { + match self { + TransferProgress::Validating => "Validating transfer parameters", + TransferProgress::InitializingTransfer => "Initializing transfer", + TransferProgress::ExtractingFlowInstance => "Extracting flow instance", + TransferProgress::SettingDebitAccount => "Setting debit account", + TransferProgress::SettingCreditAccount => "Setting credit account", + TransferProgress::SettingAmount => "Setting transfer amount", + TransferProgress::SubmittingStep7 => "Submitting intermediate step", + TransferProgress::SettingReason => "Setting transfer reason", + TransferProgress::ConfirmingTransfer => "Confirming transfer", + TransferProgress::Completed => "Transfer completed", + } + } +} +impl BoursoWebClient { + /// Initialize the transfer and extract the transfer ID + #[cfg(not(tarpaulin_include))] + async fn init_transfer(&self, from_account: &str) -> Result { let init_transfer_url = format!( "{}/compte/cav/{}/virements/immediat/nouveau", BASE_URL, from_account @@ -66,28 +85,43 @@ impl BoursoWebClient { let transfer_id = location .split('/') .nth(7) - .context("Failed to extract transfer id")?; + .context("Failed to extract transfer id")? + .to_string(); - let first_res = self - .client - .get(format!("{}{}", BASE_URL, location)) - .send() - .await?; + Ok(transfer_id) + } - if first_res.status() != 200 { - log::debug!("First transfer step response: {:?}", first_res); + /// Extract the flow instance from the HTML response + #[cfg(not(tarpaulin_include))] + async fn extract_flow_instance(&self, url: &str) -> Result { + let res = self.client.get(url).send().await?; + + if res.status() != 200 { + log::debug!("First transfer step response: {:?}", res); bail!(TransferError::TransferInitiationFailed); } - let first_res_text = first_res.text().await?; + let res_text = res.text().await?; let re = regex::Regex::new(r#"name="flow_ImmediateCashTransfer_instance" value="([^"]+)""#) .unwrap(); let flow_instance = re - .captures(&first_res_text) + .captures(&res_text) .and_then(|cap| cap.get(1)) .map(|m| m.as_str()) - .context("Failed to extract flow instance")?; + .context("Failed to extract flow instance")? + .to_string(); + Ok(flow_instance) + } + + /// Set the debit account (step 2) + #[cfg(not(tarpaulin_include))] + async fn set_debit_account( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text( "flow_ImmediateCashTransfer_instance", @@ -101,13 +135,26 @@ impl BoursoWebClient { BASE_URL, from_account, transfer_id ); - let second_res = self.client.post(&url).multipart(data).send().await?; + let res = self.client.post(&url).multipart(data).send().await?; - if second_res.status() != 200 { - log::debug!("Set debit account response: {:?}", second_res); + if res.status() != 200 { + log::debug!("Set debit account response: {:?}", res); bail!(TransferError::SetDebitAccountFailed); } + Ok(()) + } + + /// Set the credit account (step 3) + #[cfg(not(tarpaulin_include))] + async fn set_credit_account( + &self, + from_account: &str, + to_account: &str, + transfer_id: &str, + flow_instance: &str, + transfer_from_banking: bool, + ) -> Result<()> { let form = if transfer_from_banking { reqwest::multipart::Form::new().text("CreditAccount[newBeneficiary]", "0".to_string()) } else { @@ -127,13 +174,25 @@ impl BoursoWebClient { BASE_URL, from_account, transfer_id ); - let third_res = self.client.post(&url).multipart(data).send().await?; + let res = self.client.post(&url).multipart(data).send().await?; - if third_res.status() != 200 { - log::debug!("Set credit account response: {:?}", third_res); + if res.status() != 200 { + log::debug!("Set credit account response: {:?}", res); bail!(TransferError::SetCreditAccountFailed); } + Ok(()) + } + + /// Set the transfer amount (step 6) + #[cfg(not(tarpaulin_include))] + async fn set_transfer_amount( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + amount: f64, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text( "flow_ImmediateCashTransfer_instance", @@ -149,13 +208,24 @@ impl BoursoWebClient { BASE_URL, from_account, transfer_id ); - let set_amount_res = self.client.post(&url).multipart(data).send().await?; + let res = self.client.post(&url).multipart(data).send().await?; - if set_amount_res.status() != 200 { - log::debug!("Set amount response: {:?}", set_amount_res); + if res.status() != 200 { + log::debug!("Set amount response: {:?}", res); bail!(TransferError::SetAmountFailed); } + Ok(()) + } + + /// Submit step 7 + #[cfg(not(tarpaulin_include))] + async fn submit_step_7( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text("flow_ImmediateCashTransfer_transition", "".to_string()) .text( @@ -165,7 +235,7 @@ impl BoursoWebClient { .text("flow_ImmediateCashTransfer_step", "6".to_string()) .text("submit", "".to_string()); - let submit_res = self + let res = self .client .post(format!( "{}/compte/cav/{}/virements/immediat/nouveau/{}/7", @@ -175,18 +245,30 @@ impl BoursoWebClient { .send() .await?; - if submit_res.status() != 200 { - log::debug!("Submit transfer response: {:?}", submit_res); + if res.status() != 200 { + log::debug!("Submit transfer response: {:?}", res); bail!(TransferError::Step7Failed); } + Ok(()) + } + + /// Set the transfer reason (step 10) + #[cfg(not(tarpaulin_include))] + async fn set_transfer_reason( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + transfer_reason: &str, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text( "flow_ImmediateCashTransfer_instance", flow_instance.to_string(), ) .text("flow_ImmediateCashTransfer_step", "9".to_string()) - .text("Characteristics[label]", transfer_reason) // Reason for transfer + .text("Characteristics[label]", transfer_reason.to_string()) .text("Characteristics[schedulingType]", "1".to_string()) // 1 = unique .text("flow_ImmediateCashTransfer_transition", "".to_string()) .text("flow_ImmediateCashTransfer_transition", "".to_string()) @@ -197,13 +279,24 @@ impl BoursoWebClient { BASE_URL, from_account, transfer_id ); - let set_reason_res = self.client.post(&url).multipart(data).send().await?; + let res = self.client.post(&url).multipart(data).send().await?; - if set_reason_res.status() != 200 { - log::debug!("Set reason response: {:?}", set_reason_res); + if res.status() != 200 { + log::debug!("Set reason response: {:?}", res); bail!(TransferError::SetReasonFailed); } + Ok(()) + } + + /// Confirm and finalize the transfer (step 12) + #[cfg(not(tarpaulin_include))] + async fn confirm_transfer( + &self, + from_account: &str, + transfer_id: &str, + flow_instance: &str, + ) -> Result<()> { let data = reqwest::multipart::Form::new() .text( "flow_ImmediateCashTransfer_instance", @@ -214,7 +307,7 @@ impl BoursoWebClient { .text("flow_ImmediateCashTransfer_transition", "".to_string()) .text("submit", "".to_string()); - let confirm_res = self + let res = self .client .post(format!( "{}/compte/cav/{}/virements/immediat/nouveau/{}/12", @@ -224,12 +317,12 @@ impl BoursoWebClient { .send() .await?; - if confirm_res.status() != 200 { - log::debug!("Confirm transfer response: {:?}", confirm_res); + if res.status() != 200 { + log::debug!("Confirm transfer response: {:?}", res); bail!(TransferError::SubmitTransferFailed); } - let body = confirm_res.text().await?; + let body = res.text().await?; if body.as_str().contains("Confirmation") { Ok(()) @@ -238,4 +331,142 @@ impl BoursoWebClient { bail!(TransferError::InvalidTransfer); } } + + /// Transfer funds from one account to another, yielding progress updates + /// + /// ## Arguments + /// - `amount`: Amount to transfer (must be >= 10.0) + /// - `from_account`: Source account + /// - `to_account`: Destination account + /// - `reason`: Optional reason for the transfer (max 50 characters) + /// + /// ## Returns + /// A stream of progress updates for the transfer. + #[cfg(not(tarpaulin_include))] + pub fn transfer_funds( + &self, + amount: f64, + from_account: Account, + to_account: Account, + reason: Option, + ) -> impl Stream> + '_ { + async_stream::stream! { + // Validation + yield Ok(TransferProgress::Validating); + + if amount < 10.0 { + yield Err(TransferError::AmountTooLow.into()); + return; + } + + log::debug!( + "Initiating transfer of {:.2} EUR from account {} to account {}", + amount, + from_account.id, + to_account.id + ); + + let transfer_from_banking = from_account.kind == AccountKind::Banking; + let from_account_id = from_account.id.clone(); + let to_account_id = to_account.id.clone(); + + // Default reason if none provided, else use provided reason and + // warn if the reason is too long (> 50 characters) + let transfer_reason = if let Some(r) = reason { + if r.len() > 50 { + yield Err(TransferError::ReasonIsTooLong.into()); + return; + } + r + } else { + "Virement depuis BoursoBank".to_string() + }; + + // Step 1: Initialize transfer and get transfer ID + yield Ok(TransferProgress::InitializingTransfer); + let transfer_id = match self.init_transfer(&from_account_id).await { + Ok(id) => id, + Err(e) => { + yield Err(e); + return; + } + }; + + // Extract flow instance + yield Ok(TransferProgress::ExtractingFlowInstance); + let flow_instance = match self + .extract_flow_instance(&format!( + "{}/compte/cav/{}/virements/immediat/nouveau/{}/1", + BASE_URL, &from_account_id, transfer_id + )) + .await { + Ok(flow) => flow, + Err(e) => { + yield Err(e); + return; + } + }; + + // Step 2: Set debit account + yield Ok(TransferProgress::SettingDebitAccount); + if let Err(e) = self.set_debit_account(&from_account_id, &transfer_id, &flow_instance) + .await { + yield Err(e); + return; + } + + // Step 3: Set credit account + yield Ok(TransferProgress::SettingCreditAccount); + if let Err(e) = self.set_credit_account( + &from_account_id, + &to_account_id, + &transfer_id, + &flow_instance, + transfer_from_banking, + ) + .await { + yield Err(e); + return; + } + + // Step 6: Set amount + yield Ok(TransferProgress::SettingAmount); + if let Err(e) = self.set_transfer_amount(&from_account_id, &transfer_id, &flow_instance, amount) + .await { + yield Err(e); + return; + } + + // Step 7: Submit + yield Ok(TransferProgress::SubmittingStep7); + if let Err(e) = self.submit_step_7(&from_account_id, &transfer_id, &flow_instance) + .await { + yield Err(e); + return; + } + + // Step 10: Set reason + yield Ok(TransferProgress::SettingReason); + if let Err(e) = self.set_transfer_reason( + &from_account_id, + &transfer_id, + &flow_instance, + &transfer_reason, + ) + .await { + yield Err(e); + return; + } + + // Step 12: Confirm transfer + yield Ok(TransferProgress::ConfirmingTransfer); + if let Err(e) = self.confirm_transfer(&from_account_id, &transfer_id, &flow_instance) + .await { + yield Err(e); + return; + } + + yield Ok(TransferProgress::Completed); + } + } } diff --git a/src/lib.rs b/src/lib.rs index 2e71346..9876bac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,11 +3,13 @@ use bourso_api::{ account::{Account, AccountKind}, client::{ trade::{order::OrderSide, tick::QuoteTab}, + transfer::TransferProgress, BoursoWebClient, }, get_client, }; use clap::ArgMatches; +use futures_util::{pin_mut, StreamExt}; use log::{info, warn}; mod settings; @@ -292,9 +294,41 @@ pub async fn parse_matches(matches: ArgMatches) -> Result<()> { .find(|a| a.id == to_account_id) .context("To account not found. Are you sure you have access to it? Run `bourso accounts` to list your accounts")?; - let _ = web_client - .transfer_funds(amount, from_account.clone(), to_account.clone(), reason) - .await?; + let stream = web_client.transfer_funds( + amount, + from_account.clone(), + to_account.clone(), + reason.map(|s| s.to_string()), + ); + + pin_mut!(stream); + + // Track progress and update display + while let Some(progress_result) = stream.next().await { + let progress = progress_result?; + let step = progress.step_number(); + let total = TransferProgress::total_steps(); + let percentage = (step as f32 / total as f32 * 100.0) as u8; + + // Create a simple progress bar + let bar_length = 30; + let filled = (bar_length as f32 * step as f32 / total as f32) as usize; + let bar: String = "█".repeat(filled) + &"░".repeat(bar_length - filled); + + // Use ANSI escape code to clear the line before printing + // \x1B[2K clears the entire line, \r returns cursor to start + print!( + "\x1B[2K\r[{}] {:3}% - {}/{} - {}", + bar, + percentage, + step, + total, + progress.description() + ); + use std::io::Write; + std::io::stdout().flush().unwrap(); + } + println!(); // New line after progress is complete info!( "Transfer of {} from account {} to account {} successful ✅",