diff --git a/TODO.md b/TODO.md index 993e5bd..64dc6b9 100644 --- a/TODO.md +++ b/TODO.md @@ -20,9 +20,14 @@ we should have created our own platform so people can register and pay later (ei TODO: - more configuration options - - configurable proxy - summary of the operations at the end of run - how many users were added, muted? - conjoined user: add attached member to paheko - "Membre lié" - is this kind of thing even accessible on the API-level ? + +- better error handling & report to the user + - handle import error + - handle name of the service or service fee not found + +- BUG: quand l'utilisateur est déjà créé, ya un problème d'ID, le user summary n'a pas le bon id, il faut le populer depuis ce qu'on a déjà fetch diff --git a/src/helloasso.rs b/src/helloasso.rs index 9d44684..3acf4ee 100644 --- a/src/helloasso.rs +++ b/src/helloasso.rs @@ -19,11 +19,23 @@ struct WebSession { jwt: String } -#[derive(Debug)] +#[derive(Debug, Clone)] #[fully_pub] -struct Client { - client: reqwest::Client, +struct ClientConfig { base_url: Url, + proxy: Option, + user_agent: String +} + +impl Default for ClientConfig { + fn default() -> Self { + ClientConfig { + proxy: None, + base_url: Url::parse("https://api.helloasso.com/v5/") // the traling slash is important + .expect("Expected valid helloasso API base URL"), + user_agent: "".to_string() + } + } } #[derive(Serialize, Debug)] @@ -33,31 +45,47 @@ struct LoginPayload { password: String } +#[derive(Debug)] +#[fully_pub] +struct Client { + client: reqwest::Client, + config: ClientConfig +} + impl Default for Client { fn default() -> Self { + let base_config: ClientConfig = Default::default(); Client { - client: Client::get_base_client_builder() + client: Client::get_base_client_builder(&base_config) .build() - .expect("reqwest client to be built"), - base_url: Url::parse("https://api.helloasso.com/v5/") - .expect("Valid helloasso API base URL") + .expect("Expected reqwest client to be built"), + config: base_config } } } -impl Client { - fn get_base_client_builder() -> reqwest::ClientBuilder { +impl Client { + pub fn new(config: ClientConfig) -> Client { + Client { + client: Client::get_base_client_builder(&config) + .build() + .expect("Expected reqwest client to be built"), + config + } + } + + fn get_base_client_builder(config: &ClientConfig) -> reqwest::ClientBuilder { let mut default_headers = reqwest::header::HeaderMap::new(); default_headers.insert("Accept", "application/json".parse().unwrap()); - // decoy user agent - default_headers.insert("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/112.0".parse().unwrap()); + default_headers.insert("User-Agent", config.user_agent.parse().unwrap()); - // TODO: configurable proxy - // let proxy = reqwest::Proxy::https("https://localhost:8999").unwrap(); - reqwest::Client::builder() - // .proxy(proxy) - .default_headers(default_headers) + let mut builder = reqwest::Client::builder() + .default_headers(default_headers); + if let Some(proxy) = &config.proxy { + builder = builder.proxy(proxy.clone()); + } + builder } pub async fn login(&mut self, payload: LoginPayload) -> Result { @@ -67,12 +95,12 @@ impl Client { "https://auth.helloasso.com".parse().expect("Header value to be OK") ); - let res = self.client.get(self.base_url.join("auth/antiforgerytoken")?) + let res = self.client.get(self.config.base_url.join("auth/antiforgerytoken")?) .headers(login_commons_headers.clone()) .send().await?; let antiforgerytoken: String = res.json().await?; - let res = self.client.post(self.base_url.join("auth/login")?) + let res = self.client.post(self.config.base_url.join("auth/login")?) .json(&payload) .headers(login_commons_headers.clone()) .header("x-csrf-token", antiforgerytoken) @@ -107,7 +135,7 @@ impl Client { } pub fn authentified_client(&self, session: WebSession) -> AuthentifiedClient { - AuthentifiedClient::new(self.base_url.clone(), session) + AuthentifiedClient::new(self.config.clone(), session) } } @@ -116,7 +144,7 @@ impl Client { struct AuthentifiedClient { session: WebSession, client: reqwest::Client, - base_url: Url + config: ClientConfig } #[derive(Debug, Serialize, Deserialize)] @@ -167,30 +195,30 @@ struct UserDetails { impl AuthentifiedClient { /// each time we need to change the token, we will need to rebuild the client - pub fn new(base_url: Url, session: WebSession) -> Self { + pub fn new(config: ClientConfig, session: WebSession) -> Self { let mut auth_headers = reqwest::header::HeaderMap::new(); auth_headers.insert("Authorization", format!("Bearer {}", session.jwt).parse().unwrap()); AuthentifiedClient { - base_url, session, - client: Client::get_base_client_builder() + client: Client::get_base_client_builder(&config) .default_headers(auth_headers) .build() - .expect("reqwest client to be built") + .expect("reqwest client to be built"), + config } } pub async fn verify_auth(&self) -> Result { let res = self.client - .get(self.base_url.join("agg/user")?) + .get(self.config.base_url.join("agg/user")?) .send().await?; Ok(res.status() == 200) } pub async fn get_user_details(&self) -> Result { let res = self.client - .get(self.base_url.join("agg/user")?) + .get(self.config.base_url.join("agg/user")?) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); @@ -202,7 +230,7 @@ impl AuthentifiedClient { async fn simple_fetch(&self, path: String) -> Result { let res = self.client - .get(self.base_url.join(path.as_str())?) + .get(self.config.base_url.join(path.as_str())?) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); @@ -217,7 +245,7 @@ impl AuthentifiedClient { let mut continuation_token: Option = None; loop { - let mut url = self.base_url.join(path.as_str())?; + let mut url = self.config.base_url.join(path.as_str())?; if let Some(token) = &continuation_token { url.query_pairs_mut().append_pair("continuationToken", token); } diff --git a/src/main.rs b/src/main.rs index 8367cbb..2b2c824 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,22 +3,26 @@ mod paheko; mod helloasso; use thiserror::Error; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; use strum::Display; use serde::{Serialize, Deserialize}; use utils::generate_id; +use url::Url; /// permanent config to store long-term config /// used to ingest env settings /// config loaded from env variables #[derive(Deserialize, Serialize, Debug)] struct Config { + helloasso_proxy: Option, helloasso_email: String, helloasso_password: String, + paheko_proxy: Option, paheko_base_url: String, paheko_client_id: String, paheko_client_secret: String, + paheko_accounting_year_id: u64, } // start user cache management @@ -40,6 +44,7 @@ enum LoadError { FailedToWrite } +const APP_USER_AGENT: &str = "helloasso_paheko_adapter"; fn write_user_cache(cache: &UserCache) -> Result<(), LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) @@ -175,6 +180,14 @@ fn parse_and_get_birthday_year(raw_date: String) -> Option { d.year().try_into().ok() } +fn get_proxy_from_url(proxy_url: Option) -> Result> { + Ok(match proxy_url { + Some(p) => Some(reqwest::Proxy::all(&p) + .context("Expected to build Proxy from paheko_proxy config value")?), + None => None + }) +} + async fn launch_adapter() -> Result<()> { dotenvy::dotenv()?; @@ -182,7 +195,14 @@ async fn launch_adapter() -> Result<()> { let mut user_cache = load_user_cache().context("Failed to load user cache")?; - let mut paheko_client: paheko::Client = paheko::Client::new(config.paheko_base_url); + if !&config.paheko_base_url.ends_with("/") { + return Err(anyhow!("Invalid paheko base_url, it must end with a slash")) + } + let mut paheko_client: paheko::Client = paheko::Client::new(paheko::ClientConfig { + base_url: Url::parse(&config.paheko_base_url).expect("Expected paheko base url to be a valid URL"), + proxy: get_proxy_from_url(config.paheko_proxy)?, + user_agent: APP_USER_AGENT.to_string() + }); let paheko_credentials = paheko::Credentials { client_id: config.paheko_client_id, @@ -190,8 +210,12 @@ async fn launch_adapter() -> Result<()> { }; let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?; - - let mut ha_client: helloasso::Client = Default::default(); + let mut ha_client: helloasso::Client = helloasso::Client::new(helloasso::ClientConfig { + base_url: Url::parse("https://api.helloasso.com/v5/") + .expect("Expected valid helloasso API base URL"), + proxy: get_proxy_from_url(config.helloasso_proxy)?, + user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/112.0".to_string() + }); let login_payload = helloasso::LoginPayload { email: config.helloasso_email, @@ -378,6 +402,7 @@ async fn launch_adapter() -> Result<()> { // add transaction let transaction = paheko::SimpleTransaction { + accounting_year: utils::Id(config.paheko_accounting_year_id), // TODO: make the label template configurable label: format!("Adhésion {:?} via HelloAsso", pk_membership.mode_name), amount: pk_membership.payed_amount, @@ -392,7 +417,7 @@ async fn launch_adapter() -> Result<()> { linked_services: pk_user_service_registrations.iter().map(|x| x.id.clone()).collect() }; let _ = paheko_client.register_transaction(transaction) - .await.context("Expected to create new paheko transaction"); + .await.context("Expected to create new paheko transaction")?; eprintln!(" Created paheko transaction"); pk_memberships.push(pk_membership); diff --git a/src/paheko.rs b/src/paheko.rs index 0b1f4fb..ce362e5 100644 --- a/src/paheko.rs +++ b/src/paheko.rs @@ -92,17 +92,10 @@ struct SimpleTransaction { debit_account_code: String, reference: String, linked_users: Vec, - linked_services: Vec + linked_services: Vec, + accounting_year: Id } -#[derive(Debug)] -#[fully_pub] -struct Client { - client: reqwest::Client, - base_url: Url, -} - - #[derive(Error, Debug)] enum APIClientError { #[error("Received non-normal status code from API")] @@ -116,15 +109,40 @@ struct Credentials { client_secret: String } +#[derive(Debug, Clone)] +#[fully_pub] +struct ClientConfig { + base_url: Url, + proxy: Option, + user_agent: String +} + +impl Default for ClientConfig { + fn default() -> Self { + ClientConfig { + proxy: None, + base_url: Url::parse("https://paheko.example.org/api/") // the traling slash is important + .expect("Expected valid paheko API base URL"), + user_agent: "".to_string() + } + } +} + +#[derive(Debug)] +#[fully_pub] +struct Client { + client: reqwest::Client, + config: ClientConfig +} impl Default for Client { fn default() -> Self { + let base_config: ClientConfig = Default::default(); Client { - client: Client::get_base_client_builder() + client: Client::get_base_client_builder(&base_config) .build() .expect("Expected reqwest client to be built"), - base_url: Url::parse("https://paheko.etoiledebethleem.fr/api/") // the traling slash is important - .expect("Expected valid paheko API base URL") + config: base_config } } } @@ -143,25 +161,26 @@ fn build_auth_headers(credentials: &Credentials) -> reqwest::header::HeaderMap { } impl Client { - pub fn new(base_url: String) -> Client { + pub fn new(config: ClientConfig) -> Client { Client { - client: Client::get_base_client_builder() + client: Client::get_base_client_builder(&config) .build() .expect("Expected reqwest client to be built"), - base_url: Url::parse(&base_url) // the traling slash is important - .expect("Expected valid paheko API base URL") + config } } - fn get_base_client_builder() -> reqwest::ClientBuilder { + fn get_base_client_builder(config: &ClientConfig) -> reqwest::ClientBuilder { let mut default_headers = reqwest::header::HeaderMap::new(); default_headers.insert("Accept", "application/json".parse().unwrap()); - default_headers.insert("User-Agent", "helloasso_paheko_adapter".parse().unwrap()); + default_headers.insert("User-Agent", config.user_agent.parse().unwrap()); - // let proxy = reqwest::Proxy::http("http://localhost:8998").unwrap(); - reqwest::Client::builder() - // .proxy(proxy) - .default_headers(default_headers) + let mut builder = reqwest::Client::builder() + .default_headers(default_headers); + if let Some(proxy) = &config.proxy { + builder = builder.proxy(proxy.clone()); + } + builder } pub async fn login(&mut self, credentials: Credentials) -> Result { @@ -181,16 +200,16 @@ impl Client { } pub fn authentified_client(&self, credentials: Credentials) -> AuthentifiedClient { - AuthentifiedClient::new(self.base_url.clone(), credentials) + AuthentifiedClient::new(self.config.clone(), credentials) } } #[derive(Debug, Clone)] pub struct AuthentifiedClient { - credentials: Credentials, + _credentials: Credentials, client: reqwest::Client, - base_url: Url + config: ClientConfig } // SELECT id,nom AS first_name,last_name,email,external_custom_data FROM users LIMIT 5; @@ -225,14 +244,14 @@ struct UserServiceRegistration { } impl AuthentifiedClient { - pub fn new(base_url: Url, credentials: Credentials) -> Self { + pub fn new(config: ClientConfig, credentials: Credentials) -> Self { AuthentifiedClient { - client: Client::get_base_client_builder() + client: Client::get_base_client_builder(&config) .default_headers(build_auth_headers(&credentials)) .build() .expect("Expect client to be built"), - credentials, - base_url + _credentials: credentials, + config } } @@ -242,7 +261,7 @@ impl AuthentifiedClient { sql: String } let payload = Payload { sql: query }; - let path = self.base_url.join("sql")?; + let path = self.config.base_url.join("sql")?; let res = self.client .post(path) .json(&payload) @@ -329,7 +348,7 @@ impl AuthentifiedClient { .part("file", part); let res = self.client - .post(self.base_url.join("user/import/")?) + .post(self.config.base_url.join("user/import/")?) .multipart(form) .send().await?; @@ -377,7 +396,7 @@ impl AuthentifiedClient { .part("file", part); let res = self.client - .post(self.base_url.join("services/subscriptions/import")?) + .post(self.config.base_url.join("services/subscriptions/import")?) .multipart(form) .send().await?; @@ -395,25 +414,27 @@ impl AuthentifiedClient { use reqwest::multipart::Form; let mut form = Form::new() - .text("id_year", "1") + .text("id_year", transaction.accounting_year.to_string()) .text("label", transaction.label) .text("date", transaction.inception_time.format("%d/%m/%Y").to_string()) .text("type", Into::::into(transaction.kind)) .text("amount", format!("{}", transaction.amount)) .text("debit", transaction.debit_account_code) .text("credit", transaction.credit_account_code) - .text("reference", transaction.reference) + // "Numéro pièce comptable" enregistré au niveau de la transaction + .text("reference", transaction.reference) ; for linked_id in transaction.linked_users { form = form.text("linked_users[]", format!("{}", linked_id.0)); } + // only possible with paheko fork for linked_id in transaction.linked_services { form = form.text("linked_services[]", format!("{}", linked_id.0)); } let res = self.client - .post(self.base_url.join("accounting/transaction")?) + .post(self.config.base_url.join("accounting/transaction")?) .multipart(form) .send().await?;