Support downloading account statements

As described in the examples/download_statement.rs file.

Signed-off-by: Jacob Kiers <code@kiers.eu>
This commit is contained in:
Jacob Kiers 2024-03-01 07:06:52 +01:00
parent 3de7ca94a2
commit 5a9da2d29a
3 changed files with 253 additions and 4 deletions

View File

@ -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(())
}

View File

@ -6,7 +6,7 @@ use rsa::RsaPrivateKey;
use serde::{de::DeserializeOwned, Deserialize}; use serde::{de::DeserializeOwned, Deserialize};
pub use config::BunqConfig; pub use config::BunqConfig;
pub use monetary_account::MonetaryAccount; pub use monetary_account::{CsvType, MonetaryAccount, StatementFormat};
pub use payment::Payment; pub use payment::Payment;
mod config; mod config;
@ -38,6 +38,23 @@ impl BunqClient {
) -> anyhow::Result<Vec<Payment>> { ) -> anyhow::Result<Vec<Payment>> {
payment::get_from_to(self, acc, from, to) payment::get_from_to(self, acc, from, to)
} }
pub fn request_statement(
&self,
acc: &MonetaryAccount,
format: StatementFormat,
date_start: &str,
date_end: &str,
) -> Result<i64> {
monetary_account::request_statement(self, acc, format, date_start, date_end)
}
pub fn is_statement_ready(&self, acc: &MonetaryAccount, statement_id: i64) -> Result<bool> {
monetary_account::is_statement_ready(self, acc, statement_id)
}
pub fn download_statement(&self, acc: &MonetaryAccount, statement_id: i64) -> Result<String> {
monetary_account::download_statement(self, acc, statement_id)
}
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]

View File

@ -1,7 +1,12 @@
use isahc::{ReadResponseExt, RequestExt}; use std::fmt::Display;
use serde::Deserialize; 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<Vec<MonetaryAccount>> { pub(super) fn get(client: &BunqClient) -> anyhow::Result<Vec<MonetaryAccount>> {
let response = isahc::http::Request::get(format!( let response = isahc::http::Request::get(format!(
@ -18,6 +23,115 @@ pub(super) fn get(client: &BunqClient) -> anyhow::Result<Vec<MonetaryAccount>> {
Ok(accounts.response) Ok(accounts.response)
} }
pub(super) fn request_statement(
client: &BunqClient,
account: &MonetaryAccount,
format: StatementFormat,
start: &str,
end: &str,
) -> Result<i64> {
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::<CreateStatementResponse>(&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<bool> {
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::<Vec<CustomerStatementWrapper>>(&body)?
.response
.iter()
.map(|v| v.customer_statement.clone())
.take(1)
.collect::<Vec<CustomerStatement>>()
.pop()
.unwrap();
//let statement = statements.first().unwrap();
Ok(statement.status == "COMPLETED")
}
pub(super) fn download_statement(
client: &BunqClient,
account: &MonetaryAccount,
id: i64,
) -> Result<String> {
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)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub enum MonetaryAccount { pub enum MonetaryAccount {
@ -25,6 +139,15 @@ pub enum MonetaryAccount {
MonetaryAccountSavings(MonetaryAccountSavings), MonetaryAccountSavings(MonetaryAccountSavings),
} }
impl MonetaryAccount {
pub fn id(&self) -> i64 {
match &self {
MonetaryAccount::MonetaryAccountBank(b) => b.id,
MonetaryAccount::MonetaryAccountSavings(s) => s.id,
}
}
}
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct MonetaryAccountBank { pub struct MonetaryAccountBank {
pub id: i64, pub id: i64,
@ -36,3 +159,77 @@ pub struct MonetaryAccountSavings {
pub id: i64, pub id: i64,
pub description: String, 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<Self, Self::Err> {
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,
}