Compare commits

..

10 Commits

Author SHA1 Message Date
c0a444e833 Try action
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 25s
Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-03-01 07:09:28 +01:00
5a9da2d29a Support downloading account statements
As described in the examples/download_statement.rs file.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-03-01 07:09:28 +01:00
3de7ca94a2 Formatting
Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-03-01 07:09:28 +01:00
6255dc1c2f Move to Rust 2021 edition
Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-03-01 07:09:28 +01:00
c3fb162732 Update all dependencies to the latest versions
Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-03-01 07:09:28 +01:00
3ea95e9ec9 Refactor to use one module per concept
This makes code navigation easier.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-03-01 07:09:28 +01:00
2874d8dba0 Add a README.md file
Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 20:39:49 +01:00
7bbcc3b99b Update confy to show the configuration path
With the older version (v0.4.0) there was no possibility to show the
configuration path to the user, who therefore had to hunt for its
location.

By updating it, this path can now be shown during loading and storing.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 19:53:21 +01:00
95437f3549 A monetary acocunt can also be a savings account
So update the code to deal with that.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 19:53:12 +01:00
096ccec741 Use crate rsa instead of openssl
The rsa crate contains a pure rust implementation of the same
primitives.

This helps with cross platform support, such as Windows.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 19:20:24 +01:00
9 changed files with 633 additions and 267 deletions

View File

@ -0,0 +1,20 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: actions/checkout@v3
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ gitea.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}. And this is the last piece of the action."

View File

@ -6,15 +6,15 @@ keywords = ["bunq", "api"]
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/yuri91/bunq-rs" repository = "https://github.com/yuri91/bunq-rs"
authors = ["Yuri Iozzelli <y.iozzelli@gmail.com>"] authors = ["Yuri Iozzelli <y.iozzelli@gmail.com>"]
edition = "2018" edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.32" anyhow = "1.0.79"
isahc = { version = "0.9.8", features = ["json"] } base64 = "0.21.7"
confy = "0.6.0"
dotenv = "0.15.0" dotenv = "0.15.0"
openssl = "0.10.30" isahc = { version = "1.7.2", features = ["json"] }
serde_json = "1.0.57" rand = "0.8.5"
serde = { version = "1.0.116", features = ["derive"] } rsa = { version = "0.9.6", features = ["sha2"] }
base64 = "0.12.3" serde = { version = "1.0.196", features = ["derive"] }
confy = "0.4.0" serde_json = "1.0.113"

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# bunq-rs
bunq-rs is a Rust wrapper around the [bunq API][0].
It is configured (with [confy][1]) by creating a `bunq-rs.toml` file in
the OS configuration directory, and with the following contents:
```yaml
api_key = '<bunq API key>'
```
The api will take care to update this file with all the requisite information.
[0]: https://doc.bunq.com/
[1]: https://docs.rs/confy/

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

@ -5,8 +5,10 @@ fn main() -> Result<()> {
let cfg = BunqConfig::load()?; let cfg = BunqConfig::load()?;
let cfg = cfg.install()?; let cfg = cfg.install()?;
let accs = cfg.monetary_accounts()?; let accs = cfg.monetary_accounts()?;
println!("{:#?}", accs); println!("{:#?}", accs);
let acc = &accs[0]; let acc = &accs[0];
let ps = cfg.payments(acc)?; let ps = cfg.payments(acc)?;
println!("{:#?}", ps); println!("{:#?}", ps);

149
src/config.rs Normal file
View File

@ -0,0 +1,149 @@
use anyhow::anyhow;
use isahc::http::StatusCode;
use isahc::{ReadResponseExt, RequestExt};
use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, LineEnding};
use rsa::pkcs8::EncodePublicKey;
use rsa::RsaPrivateKey;
use serde::{Deserialize, Serialize};
use crate::{deserialize_retarded_response, sign, BunqClient, BASE_URL};
#[derive(Serialize, Deserialize, Default)]
struct AppState {
token: String,
pem_private: String,
}
#[derive(Serialize, Deserialize, Default)]
pub struct BunqConfig {
api_key: String,
state: Option<AppState>,
}
impl BunqConfig {
pub fn load() -> anyhow::Result<BunqConfig> {
println!(
"Loading config file from {}",
confy::get_configuration_file_path("bunq-rs", "bunq-rs")?.to_string_lossy()
);
Ok(confy::load("bunq-rs", "bunq-rs")?)
}
pub fn save(&self) -> anyhow::Result<()> {
println!(
"Storing config file in {}",
confy::get_configuration_file_path("bunq-rs", None)?.to_string_lossy()
);
confy::store("bunq-rs", "bunq-rs", self)?;
Ok(())
}
pub fn install(mut self) -> anyhow::Result<BunqClient> {
let api_key = &self.api_key;
let keypair = if let Some(state) = &self.state {
RsaPrivateKey::from_pkcs1_pem(&state.pem_private)?
} else {
let mut rng = rand::thread_rng();
let bits = 2048;
let keypair = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key");
let pem_public = keypair
.to_public_key()
.to_public_key_pem(LineEnding::CRLF)?;
let body = Installation {
client_public_key: &pem_public,
};
let response = isahc::post(
format!("{}/v1/installation", BASE_URL),
serde_json::to_string(&body)?,
)?
.text()?;
let response: InstallationResponse = deserialize_retarded_response(&response)?.response;
let token = response.token.token;
let body = DeviceServer {
description: "JK Test Server",
secret: api_key,
permitted_ips: &["77.175.86.236", "*"],
};
let body = serde_json::to_string(&body)?;
let mut response = isahc::http::Request::post(format!("{}/v1/device-server", BASE_URL))
.header("X-Bunq-Client-Authentication", &token)
.body(body)?
.send()?;
let response_text = response.text()?;
println!("{}", response_text);
if response.status() != StatusCode::OK {
return Err(anyhow!(response_text));
}
self.state = Some(AppState {
pem_private: keypair.to_pkcs1_pem(LineEnding::CRLF)?.to_string(),
token,
});
self.save()?;
keypair
};
let token = self.state.unwrap().token;
let body = SessionServer { secret: api_key };
let body = serde_json::to_string(&body)?;
let sig = sign(&body, &keypair)?;
let response = isahc::http::Request::post(format!("{}/v1/session-server", BASE_URL))
.header("X-Bunq-Client-Authentication", &token)
.header("X-Bunq-Client-Signature", &sig)
.body(body)?
.send()?
.text()?;
let r: SessionServerResponse = deserialize_retarded_response(&response)?.response;
Ok(BunqClient {
keypair,
token: r.token.token,
user_id: r.user_person.id,
})
}
}
#[derive(Serialize)]
pub(super) struct Installation<'a> {
pub(super) client_public_key: &'a str,
}
#[derive(Deserialize)]
pub(super) struct Token {
pub(super) token: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
pub(super) struct InstallationResponse {
pub(super) token: Token,
}
#[derive(Serialize)]
pub(super) struct DeviceServer<'a> {
pub(super) description: &'a str,
pub(super) secret: &'a str,
pub(super) permitted_ips: &'a [&'a str],
}
#[derive(Serialize)]
pub(super) struct SessionServer<'a> {
pub(super) secret: &'a str,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
pub(super) struct SessionServerResponse {
pub(super) token: Token,
pub(super) user_person: UserPerson,
}
#[derive(Deserialize)]
pub(super) struct UserPerson {
pub(super) id: i64,
}

View File

@ -1,58 +1,74 @@
use anyhow::Result; use anyhow::Result;
use isahc::prelude::*; use rsa::pkcs1v15::SigningKey;
use openssl::hash::MessageDigest; use rsa::sha2::Sha256;
use openssl::pkey::PKey; use rsa::signature::RandomizedSigner;
use openssl::rsa::Rsa; use rsa::RsaPrivateKey;
use openssl::sign::Signer; use serde::{de::DeserializeOwned, Deserialize};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
const BASE: &str = "https://api.bunq.com"; pub use config::BunqConfig;
pub use monetary_account::{CsvType, MonetaryAccount, StatementFormat};
pub use payment::Payment;
#[derive(Serialize)] mod config;
struct Installation<'a> { mod monetary_account;
client_public_key: &'a str, mod payment;
}
#[derive(Deserialize)] const BASE_URL: &str = "https://api.bunq.com";
struct Token {
pub struct BunqClient {
token: String, token: String,
} #[allow(dead_code)] // Required for signing bodies. Not used yet.
#[derive(Deserialize)] keypair: RsaPrivateKey,
#[serde(rename_all = "PascalCase")] user_id: i64,
struct InstallationResponse {
token: Token,
} }
#[derive(Serialize)] impl BunqClient {
struct DeviceServer<'a> { pub fn monetary_accounts(&self) -> Result<Vec<MonetaryAccount>> {
description: &'a str, monetary_account::get(self)
secret: &'a str, }
permitted_ips: &'a [&'a str],
pub fn payments(&self, acc: &MonetaryAccount) -> Result<Vec<Payment>> {
self.payments_from_to(acc, None, None)
}
pub fn payments_from_to(
&self,
acc: &MonetaryAccount,
from: Option<i64>,
to: Option<i64>,
) -> anyhow::Result<Vec<Payment>> {
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(Serialize)] #[derive(Deserialize, Debug)]
struct SessionServer<'a> { struct Pagination {
secret: &'a str, #[allow(dead_code)] // Used for refresh. Not implemented yet.
future_url: Option<String>,
#[allow(dead_code)] // Used for finding newer items. Not necessary yet.
newer_url: Option<String>,
older_url: Option<String>,
} }
#[derive(Deserialize)] struct Response<T> {
#[serde(rename_all = "PascalCase")] response: T,
struct SessionServerResponse { pagination: Option<Pagination>,
token: Token,
user_person: UserPerson,
}
#[derive(Deserialize)]
struct UserPerson {
id: i64,
}
fn sign<K: openssl::pkey::HasPrivate>(body: &str, key: &PKey<K>) -> Result<String> {
let mut signer = Signer::new(MessageDigest::sha256(), key)?;
let sig = signer.sign_oneshot_to_vec(body.as_bytes())?;
Ok(base64::encode(&sig))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -62,11 +78,6 @@ struct RawResponse {
pagination: Option<Pagination>, pagination: Option<Pagination>,
} }
struct Response<T> {
response: T,
pagination: Option<Pagination>,
}
impl RawResponse { impl RawResponse {
fn decode_retarded<T: DeserializeOwned>(self) -> Result<Response<T>> { fn decode_retarded<T: DeserializeOwned>(self) -> Result<Response<T>> {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
@ -99,213 +110,11 @@ fn deserialize_normal_response<T: DeserializeOwned>(r: &str) -> Result<Response<
}) })
} }
#[derive(Serialize, Deserialize, Default)] fn sign(body: &str, key: &RsaPrivateKey) -> Result<String> {
struct AppState { use base64::prelude::{Engine, BASE64_STANDARD};
token: String,
pem_private: String, let signing_key = SigningKey::<Sha256>::new(key.clone());
} let mut rng = rand::thread_rng();
#[derive(Serialize, Deserialize, Default)] let signature = signing_key.sign_with_rng(&mut rng, body.as_bytes());
pub struct BunqConfig { Ok(BASE64_STANDARD.encode(signature.to_string()))
api_key: String,
state: Option<AppState>,
}
pub struct BunqConfigReady {
token: String,
keypair: PKey<openssl::pkey::Private>,
user_id: i64,
}
impl BunqConfig {
pub fn load() -> Result<BunqConfig> {
Ok(confy::load("bunq-rs")?)
}
pub fn save(&self) -> Result<()> {
confy::store("bunq-rs", self)?;
Ok(())
}
pub fn install(mut self) -> Result<BunqConfigReady> {
let api_key = &self.api_key;
let keypair = if let Some(state) = &self.state {
PKey::private_key_from_pem(state.pem_private.as_bytes())?
} else {
let rsa = Rsa::generate(2048)?;
let pem_private = rsa.private_key_to_pem()?;
let pem_private = String::from_utf8(pem_private)?;
let keypair = PKey::from_rsa(rsa)?;
let pem_public = String::from_utf8(keypair.public_key_to_pem()?)?;
let body = Installation {
client_public_key: &pem_public,
};
let response = isahc::post(
format!("{}/v1/installation", BASE),
serde_json::to_string(&body)?,
)?
.text()?;
let response: InstallationResponse = deserialize_retarded_response(&response)?.response;
let token = response.token.token;
let body = DeviceServer {
description: "awesome",
secret: &api_key,
permitted_ips: &["31.21.118.143", "*"],
};
let body = serde_json::to_string(&body)?;
let mut response = isahc::http::Request::post(format!("{}/v1/device-server", BASE))
.header("X-Bunq-Client-Authentication", &token)
.body(body)?
.send()?;
println!("{}", response.text()?);
self.state = Some(AppState { pem_private, token });
self.save()?;
keypair
};
let token = self.state.unwrap().token;
let body = SessionServer { secret: &api_key };
let body = serde_json::to_string(&body)?;
let sig = sign(&body, &keypair)?;
let response = isahc::http::Request::post(format!("{}/v1/session-server", BASE))
.header("X-Bunq-Client-Authentication", &token)
.header("X-Bunq-Client-Signature", &sig)
.body(body)?
.send()?
.text()?;
let r: SessionServerResponse = deserialize_retarded_response(&response)?.response;
Ok(BunqConfigReady {
keypair,
token: r.token.token,
user_id: r.user_person.id,
})
}
}
impl BunqConfigReady {
pub fn monetary_accounts(&self) -> Result<Vec<MonetaryAccountBank>> {
let response = isahc::http::Request::get(format!(
"{}/v1/user/{}/monetary-account",
BASE, self.user_id
))
.header("X-Bunq-Client-Authentication", &self.token)
.body(())?
.send()?
.text()?;
Ok(
deserialize_normal_response::<Vec<MonetaryAccount>>(&response)?
.response
.into_iter()
.map(|m| m.monetary_account_bank)
.collect(),
)
}
pub fn payments(&self, acc: &MonetaryAccountBank) -> Result<Vec<Payment>> {
self.payments_from_to(acc, None, None)
}
pub fn payments_from_to(
&self,
acc: &MonetaryAccountBank,
from: Option<i64>,
to: Option<i64>,
) -> Result<Vec<Payment>> {
let next_page = |url: &str| -> Result<(_, _)> {
let response = isahc::http::Request::get(url)
.header("X-Bunq-Client-Authentication", &self.token)
.body(())?
.send()?
.text()?;
let Response {
response,
pagination,
} = deserialize_normal_response::<Vec<PaymentPayment>>(&response)?;
Ok((
response.into_iter().map(|p| p.payment).collect(),
pagination,
))
};
let mut url = format!(
"/v1/user/{}/monetary-account/{}/payment",
self.user_id, acc.id
);
if let Some(to) = to {
url = format!("{}?newer_id={}", url, to);
}
let mut all = Vec::new();
loop {
let (mut payments, pag) = next_page(&format!("{}{}", BASE, url))?;
all.append(&mut payments);
dbg!(&pag);
if let Some(Pagination {
older_url: Some(older_url),
..
}) = pag
{
if let (Some(latest), Some(from)) = (all.last(), from) {
if latest.id <= from {
all = all.into_iter().filter(|p| p.id >= from).collect();
break;
}
}
url = older_url;
} else {
break;
}
}
Ok(all)
}
}
#[derive(Deserialize, Debug)]
pub struct LabelMonetaryAccount {
pub iban: Option<String>,
pub display_name: String,
pub merchant_category_code: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Amount {
pub value: String,
pub currency: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct PaymentPayment {
payment: Payment,
}
#[derive(Deserialize, Debug)]
struct Pagination {
future_url: Option<String>,
newer_url: Option<String>,
older_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Payment {
pub alias: LabelMonetaryAccount,
pub counterparty_alias: LabelMonetaryAccount,
pub amount: Amount,
pub balance_after_mutation: Amount,
pub created: String,
pub updated: String,
pub description: String,
pub id: i64,
pub monetary_account_id: i64,
#[serde(rename = "type")]
pub type_: String,
pub sub_type: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct MonetaryAccount {
monetary_account_bank: MonetaryAccountBank,
}
#[derive(Deserialize, Debug)]
pub struct MonetaryAccountBank {
pub id: i64,
pub description: String,
} }

235
src/monetary_account.rs Normal file
View File

@ -0,0 +1,235 @@
use std::fmt::Display;
use std::str::FromStr;
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>> {
let response = isahc::http::Request::get(format!(
"{}/v1/user/{}/monetary-account",
BASE_URL, client.user_id
))
.header("X-Bunq-Client-Authentication", &client.token)
.body(())?
.send()?
.text()?;
let accounts = crate::deserialize_normal_response::<Vec<MonetaryAccount>>(&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)]
#[serde(rename_all = "PascalCase")]
pub enum MonetaryAccount {
MonetaryAccountBank(MonetaryAccountBank),
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,
pub description: String,
}
#[derive(Deserialize, Debug)]
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<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,
}

101
src/payment.rs Normal file
View File

@ -0,0 +1,101 @@
use anyhow::Result;
use isahc::{ReadResponseExt, RequestExt};
use serde::Deserialize;
use crate::{BunqClient, MonetaryAccount, Pagination, Response, BASE_URL};
pub(super) fn get_from_to(
client: &BunqClient,
acc: &MonetaryAccount,
from: Option<i64>,
to: Option<i64>,
) -> Result<Vec<Payment>> {
let next_page = |url: &str| -> Result<(_, _)> {
let response = isahc::http::Request::get(url)
.header("X-Bunq-Client-Authentication", &client.token)
.body(())?
.send()?
.text()?;
let Response {
response,
pagination,
} = crate::deserialize_normal_response::<Vec<PaymentPayment>>(&response)?;
Ok((
response.into_iter().map(|p| p.payment).collect(),
pagination,
))
};
let account_id = match acc {
MonetaryAccount::MonetaryAccountBank(bank) => bank.id,
MonetaryAccount::MonetaryAccountSavings(savings) => savings.id,
};
let mut url = format!(
"/v1/user/{}/monetary-account/{}/payment",
client.user_id, account_id
);
if let Some(to) = to {
url = format!("{}?newer_id={}", url, to);
}
let mut all = Vec::new();
loop {
let (mut payments, pag) = next_page(&format!("{}{}", BASE_URL, url))?;
dbg!(&payments);
all.append(&mut payments);
dbg!(&pag);
if let Some(Pagination {
older_url: Some(older_url),
..
}) = pag
{
if let (Some(latest), Some(from)) = (all.last(), from) {
if latest.id <= from {
all.retain(|p| p.id >= from);
break;
}
}
url = older_url;
} else {
break;
}
}
Ok(all)
}
#[derive(Deserialize, Debug)]
pub struct LabelMonetaryAccount {
pub iban: Option<String>,
pub display_name: String,
pub merchant_category_code: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Amount {
pub value: String,
pub currency: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct PaymentPayment {
payment: Payment,
}
#[derive(Deserialize, Debug)]
pub struct Payment {
pub alias: LabelMonetaryAccount,
pub counterparty_alias: LabelMonetaryAccount,
pub amount: Amount,
pub balance_after_mutation: Amount,
pub created: String,
pub updated: String,
pub description: String,
pub id: i64,
pub monetary_account_id: i64,
#[serde(rename = "type")]
pub type_: String,
pub sub_type: String,
}