Compare commits

..

2 Commits

Author SHA1 Message Date
4d347a657c Update all dependencies to the latest versions
Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 21:59:45 +01:00
54cec9ac04 Refactor to use one module per concept
This makes code navigation easier.

Signed-off-by: Jacob Kiers <code@kiers.eu>
2024-02-29 21:41:11 +01:00
8 changed files with 281 additions and 561 deletions

View File

@@ -1,20 +0,0 @@
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,7 +6,8 @@ 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 = "2021" edition = "2018"
[dependencies] [dependencies]
anyhow = "1.0.79" anyhow = "1.0.79"

View File

@@ -1,35 +0,0 @@
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

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

0
src/installation.rs Normal file
View File

View File

@@ -1,12 +1,12 @@
use anyhow::Result; use anyhow::Result;
use rsa::pkcs1v15::SigningKey; use rsa::pkcs1v15::SigningKey;
use rsa::RsaPrivateKey;
use rsa::sha2::Sha256; use rsa::sha2::Sha256;
use rsa::signature::RandomizedSigner; use rsa::signature::RandomizedSigner;
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::{CsvType, MonetaryAccount, StatementFormat}; pub use monetary_account::MonetaryAccount;
pub use payment::Payment; pub use payment::Payment;
mod config; mod config;
@@ -38,23 +38,6 @@ 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)]
@@ -111,7 +94,7 @@ fn deserialize_normal_response<T: DeserializeOwned>(r: &str) -> Result<Response<
} }
fn sign(body: &str, key: &RsaPrivateKey) -> Result<String> { fn sign(body: &str, key: &RsaPrivateKey) -> Result<String> {
use base64::prelude::{Engine, BASE64_STANDARD}; use base64::prelude::{BASE64_STANDARD, Engine};
let signing_key = SigningKey::<Sha256>::new(key.clone()); let signing_key = SigningKey::<Sha256>::new(key.clone());
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();

View File

@@ -1,235 +1,38 @@
use std::fmt::Display; use isahc::{ReadResponseExt, RequestExt};
use std::str::FromStr; use serde::Deserialize;
use anyhow::{anyhow, Result}; use crate::{BASE_URL, BunqClient};
use isahc::http::StatusCode;
use isahc::{ReadResponseExt, RequestExt}; pub(super) fn get(client: &BunqClient) -> anyhow::Result<Vec<MonetaryAccount>> {
use serde::{Deserialize, Serialize}; let response = isahc::http::Request::get(format!(
"{}/v1/user/{}/monetary-account",
use crate::{deserialize_normal_response, sign, BunqClient, BASE_URL}; BASE_URL, client.user_id
))
pub(super) fn get(client: &BunqClient) -> anyhow::Result<Vec<MonetaryAccount>> { .header("X-Bunq-Client-Authentication", &client.token)
let response = isahc::http::Request::get(format!( .body(())?
"{}/v1/user/{}/monetary-account", .send()?
BASE_URL, client.user_id .text()?;
))
.header("X-Bunq-Client-Authentication", &client.token) let accounts = crate::deserialize_normal_response::<Vec<MonetaryAccount>>(&response)?;
.body(())?
.send()? Ok(accounts.response)
.text()?; }
let accounts = crate::deserialize_normal_response::<Vec<MonetaryAccount>>(&response)?; #[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
Ok(accounts.response) pub enum MonetaryAccount {
} MonetaryAccountBank(MonetaryAccountBank),
MonetaryAccountSavings(MonetaryAccountSavings),
pub(super) fn request_statement( }
client: &BunqClient,
account: &MonetaryAccount, #[derive(Deserialize, Debug)]
format: StatementFormat, pub struct MonetaryAccountBank {
start: &str, pub id: i64,
end: &str, pub description: String,
) -> Result<i64> { }
let statement_request = CreateStatementRequest {
statement_format: &format.to_string(), #[derive(Deserialize, Debug)]
regional_format: match format { pub struct MonetaryAccountSavings {
StatementFormat::CSV(CsvType::Comma) => "UK_US", pub id: i64,
StatementFormat::CSV(CsvType::Semicolon) => "EUROPEAN", pub description: String,
_ => "", }
},
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,
}

View File

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