diff --git a/examples/download_statement.rs b/examples/download_statement.rs new file mode 100644 index 0000000..3d3e076 --- /dev/null +++ b/examples/download_statement.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use bunq::{BunqConfig, CsvType, StatementFormat}; +use std::time::Duration; + +fn main() -> Result<()> { + let cfg = BunqConfig::load()?; + + let client = cfg.install()?; + + let accs = client.monetary_accounts()?; + + let acc = &accs[0]; + + let stmt_id = client.request_statement( + acc, + StatementFormat::CSV(CsvType::Semicolon), + "2023-12-05", + "2023-12-15", + )?; + + println!("Waiting for statement {:?}...", stmt_id); + + let mut ready = client.is_statement_ready(acc, stmt_id)?; + while !ready { + println!("Statement is not ready yet. Waiting 5 seconds..."); + std::thread::sleep(Duration::from_secs(5)); + ready = client.is_statement_ready(acc, stmt_id)?; + } + + let contents = client.download_statement(acc, stmt_id)?; + + println!("{contents}"); + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 9952caf..a7b3f87 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ use rsa::RsaPrivateKey; use serde::{de::DeserializeOwned, Deserialize}; pub use config::BunqConfig; -pub use monetary_account::MonetaryAccount; +pub use monetary_account::{CsvType, MonetaryAccount, StatementFormat}; pub use payment::Payment; mod config; @@ -38,6 +38,23 @@ impl BunqClient { ) -> anyhow::Result> { payment::get_from_to(self, acc, from, to) } + + pub fn request_statement( + &self, + acc: &MonetaryAccount, + format: StatementFormat, + date_start: &str, + date_end: &str, + ) -> Result { + monetary_account::request_statement(self, acc, format, date_start, date_end) + } + pub fn is_statement_ready(&self, acc: &MonetaryAccount, statement_id: i64) -> Result { + monetary_account::is_statement_ready(self, acc, statement_id) + } + + pub fn download_statement(&self, acc: &MonetaryAccount, statement_id: i64) -> Result { + monetary_account::download_statement(self, acc, statement_id) + } } #[derive(Deserialize, Debug)] diff --git a/src/monetary_account.rs b/src/monetary_account.rs index f43c74a..b11e668 100644 --- a/src/monetary_account.rs +++ b/src/monetary_account.rs @@ -1,7 +1,12 @@ -use isahc::{ReadResponseExt, RequestExt}; -use serde::Deserialize; +use std::fmt::Display; +use std::str::FromStr; -use crate::{BunqClient, BASE_URL}; +use anyhow::{anyhow, Result}; +use isahc::http::StatusCode; +use isahc::{ReadResponseExt, RequestExt}; +use serde::{Deserialize, Serialize}; + +use crate::{deserialize_normal_response, sign, BunqClient, BASE_URL}; pub(super) fn get(client: &BunqClient) -> anyhow::Result> { let response = isahc::http::Request::get(format!( @@ -18,6 +23,115 @@ pub(super) fn get(client: &BunqClient) -> anyhow::Result> { Ok(accounts.response) } +pub(super) fn request_statement( + client: &BunqClient, + account: &MonetaryAccount, + format: StatementFormat, + start: &str, + end: &str, +) -> Result { + let statement_request = CreateStatementRequest { + statement_format: &format.to_string(), + regional_format: match format { + StatementFormat::CSV(CsvType::Comma) => "UK_US", + StatementFormat::CSV(CsvType::Semicolon) => "EUROPEAN", + _ => "", + }, + date_start: start, + date_end: end, + include_attachment: match format { + StatementFormat::PDF(v) => v, + _ => false, + }, + }; + + let body = serde_json::to_string(&statement_request)?; + let sig = sign(&body, &client.keypair)?; + + let request_url = format!( + "{}/v1/user/{}/monetary-account/{}/customer-statement", + BASE_URL, + client.user_id, + account.id() + ); + + let mut response = isahc::http::Request::post(request_url) + .header("X-Bunq-Client-Authentication", &client.token) + .header("X-Bunq-Client-Signature", &sig) + .body(body)? + .send()?; + + if response.status() != StatusCode::OK { + return Err(anyhow!(response.text()?)); + } + + let response_body = response.text()?; + + //let response_body = r#"{"Response":[{"Id":{"id":4203007}}]}"#; + //println!("{response_body}"); + + let r = deserialize_normal_response::(&response_body)?.response; + + let statement_id = r.id.id.id; + + Ok(statement_id) +} + +pub(super) fn is_statement_ready( + client: &BunqClient, + account: &MonetaryAccount, + statement_id: i64, +) -> Result { + let url = format!( + "{}/v1/user/{}/monetary-account/{}/customer-statement/{}", + BASE_URL, + client.user_id, + account.id(), + statement_id + ); + + let mut response = isahc::http::Request::get(url) + .header("X-Bunq-Client-Authentication", &client.token) + .body(())? + .send()?; + + let body = response.text()?; + + let statement = deserialize_normal_response::>(&body)? + .response + .iter() + .map(|v| v.customer_statement.clone()) + .take(1) + .collect::>() + .pop() + .unwrap(); + + //let statement = statements.first().unwrap(); + + Ok(statement.status == "COMPLETED") +} + +pub(super) fn download_statement( + client: &BunqClient, + account: &MonetaryAccount, + id: i64, +) -> Result { + let content_url = format!( + "{}/v1/user/{}/monetary-account/{}/customer-statement/{}/content", + BASE_URL, + client.user_id, + account.id(), + id + ); + + let mut response = isahc::http::Request::get(content_url) + .header("X-Bunq-Client-Authentication", &client.token) + .body(())? + .send()?; + + Ok(response.text()?) +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "PascalCase")] pub enum MonetaryAccount { @@ -25,6 +139,15 @@ pub enum MonetaryAccount { MonetaryAccountSavings(MonetaryAccountSavings), } +impl MonetaryAccount { + pub fn id(&self) -> i64 { + match &self { + MonetaryAccount::MonetaryAccountBank(b) => b.id, + MonetaryAccount::MonetaryAccountSavings(s) => s.id, + } + } +} + #[derive(Deserialize, Debug)] pub struct MonetaryAccountBank { pub id: i64, @@ -36,3 +159,77 @@ pub struct MonetaryAccountSavings { pub id: i64, pub description: String, } + +pub enum CsvType { + Comma, + Semicolon, +} + +pub enum StatementFormat { + CSV(CsvType), + MT940, + PDF(bool), +} + +impl Display for StatementFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + StatementFormat::CSV(_) => "CSV", + StatementFormat::MT940 => "MT940", + StatementFormat::PDF(_) => "PDF", + }; + write!(f, "{}", str) + } +} + +impl FromStr for StatementFormat { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.to_ascii_uppercase().as_str() { + "CSV" => Ok(StatementFormat::CSV(CsvType::Semicolon)), + "MT940" => Ok(StatementFormat::MT940), + "PDF" => Ok(StatementFormat::PDF(false)), + _ => Err(()), + } + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct CustomerStatementWrapper { + customer_statement: CustomerStatement, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct CustomerStatement { + pub id: i64, + status: String, +} + +#[derive(Serialize)] +struct CreateStatementRequest<'a> { + statement_format: &'a str, + date_start: &'a str, + date_end: &'a str, + regional_format: &'a str, + include_attachment: bool, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct CreateStatementResponse { + id: Id, +} + +//#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct Id { + id: Id2, +} + +#[derive(Deserialize, Debug)] +struct Id2 { + id: i64, +}