Compare commits
10 Commits
master
...
improvemen
Author | SHA1 | Date | |
---|---|---|---|
c0a444e833 | |||
5a9da2d29a | |||
3de7ca94a2 | |||
6255dc1c2f | |||
c3fb162732 | |||
3ea95e9ec9 | |||
2874d8dba0 | |||
7bbcc3b99b | |||
95437f3549 | |||
096ccec741 |
20
.gitea/workflows/test.yaml
Normal file
20
.gitea/workflows/test.yaml
Normal 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."
|
||||
|
18
Cargo.toml
18
Cargo.toml
@ -6,15 +6,15 @@ keywords = ["bunq", "api"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/yuri91/bunq-rs"
|
||||
authors = ["Yuri Iozzelli <y.iozzelli@gmail.com>"]
|
||||
edition = "2018"
|
||||
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.32"
|
||||
isahc = { version = "0.9.8", features = ["json"] }
|
||||
anyhow = "1.0.79"
|
||||
base64 = "0.21.7"
|
||||
confy = "0.6.0"
|
||||
dotenv = "0.15.0"
|
||||
openssl = "0.10.30"
|
||||
serde_json = "1.0.57"
|
||||
serde = { version = "1.0.116", features = ["derive"] }
|
||||
base64 = "0.12.3"
|
||||
confy = "0.4.0"
|
||||
isahc = { version = "1.7.2", features = ["json"] }
|
||||
rand = "0.8.5"
|
||||
rsa = { version = "0.9.6", features = ["sha2"] }
|
||||
serde = { version = "1.0.196", features = ["derive"] }
|
||||
serde_json = "1.0.113"
|
||||
|
15
README.md
Normal file
15
README.md
Normal 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/
|
35
examples/download_statement.rs
Normal file
35
examples/download_statement.rs
Normal 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(())
|
||||
}
|
@ -5,8 +5,10 @@ fn main() -> Result<()> {
|
||||
let cfg = BunqConfig::load()?;
|
||||
|
||||
let cfg = cfg.install()?;
|
||||
|
||||
let accs = cfg.monetary_accounts()?;
|
||||
println!("{:#?}", accs);
|
||||
|
||||
let acc = &accs[0];
|
||||
let ps = cfg.payments(acc)?;
|
||||
println!("{:#?}", ps);
|
||||
|
149
src/config.rs
Normal file
149
src/config.rs
Normal 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,
|
||||
}
|
313
src/lib.rs
313
src/lib.rs
@ -1,58 +1,74 @@
|
||||
use anyhow::Result;
|
||||
use isahc::prelude::*;
|
||||
use openssl::hash::MessageDigest;
|
||||
use openssl::pkey::PKey;
|
||||
use openssl::rsa::Rsa;
|
||||
use openssl::sign::Signer;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
use rsa::sha2::Sha256;
|
||||
use rsa::signature::RandomizedSigner;
|
||||
use rsa::RsaPrivateKey;
|
||||
use serde::{de::DeserializeOwned, Deserialize};
|
||||
|
||||
const BASE: &str = "https://api.bunq.com";
|
||||
pub use config::BunqConfig;
|
||||
pub use monetary_account::{CsvType, MonetaryAccount, StatementFormat};
|
||||
pub use payment::Payment;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Installation<'a> {
|
||||
client_public_key: &'a str,
|
||||
}
|
||||
mod config;
|
||||
mod monetary_account;
|
||||
mod payment;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Token {
|
||||
const BASE_URL: &str = "https://api.bunq.com";
|
||||
|
||||
pub struct BunqClient {
|
||||
token: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct InstallationResponse {
|
||||
token: Token,
|
||||
#[allow(dead_code)] // Required for signing bodies. Not used yet.
|
||||
keypair: RsaPrivateKey,
|
||||
user_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeviceServer<'a> {
|
||||
description: &'a str,
|
||||
secret: &'a str,
|
||||
permitted_ips: &'a [&'a str],
|
||||
impl BunqClient {
|
||||
pub fn monetary_accounts(&self) -> Result<Vec<MonetaryAccount>> {
|
||||
monetary_account::get(self)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionServer<'a> {
|
||||
secret: &'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)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct SessionServerResponse {
|
||||
token: Token,
|
||||
user_person: UserPerson,
|
||||
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)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserPerson {
|
||||
id: i64,
|
||||
pub fn download_statement(&self, acc: &MonetaryAccount, statement_id: i64) -> Result<String> {
|
||||
monetary_account::download_statement(self, acc, statement_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn sign<K: openssl::pkey::HasPrivate>(body: &str, key: &PKey<K>) -> Result<String> {
|
||||
let mut signer = Signer::new(MessageDigest::sha256(), key)?;
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct Pagination {
|
||||
#[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>,
|
||||
}
|
||||
|
||||
let sig = signer.sign_oneshot_to_vec(body.as_bytes())?;
|
||||
|
||||
Ok(base64::encode(&sig))
|
||||
struct Response<T> {
|
||||
response: T,
|
||||
pagination: Option<Pagination>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -62,11 +78,6 @@ struct RawResponse {
|
||||
pagination: Option<Pagination>,
|
||||
}
|
||||
|
||||
struct Response<T> {
|
||||
response: T,
|
||||
pagination: Option<Pagination>,
|
||||
}
|
||||
|
||||
impl RawResponse {
|
||||
fn decode_retarded<T: DeserializeOwned>(self) -> Result<Response<T>> {
|
||||
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)]
|
||||
struct AppState {
|
||||
token: String,
|
||||
pem_private: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct BunqConfig {
|
||||
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;
|
||||
fn sign(body: &str, key: &RsaPrivateKey) -> Result<String> {
|
||||
use base64::prelude::{Engine, BASE64_STANDARD};
|
||||
|
||||
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,
|
||||
let signing_key = SigningKey::<Sha256>::new(key.clone());
|
||||
let mut rng = rand::thread_rng();
|
||||
let signature = signing_key.sign_with_rng(&mut rng, body.as_bytes());
|
||||
Ok(BASE64_STANDARD.encode(signature.to_string()))
|
||||
}
|
||||
|
235
src/monetary_account.rs
Normal file
235
src/monetary_account.rs
Normal 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
101
src/payment.rs
Normal 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,
|
||||
}
|
Loading…
Reference in New Issue
Block a user