mod utils; mod paheko; mod helloasso; use thiserror::Error; use anyhow::{Context, Result}; use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; use strum::Display; use serde::{Serialize, Deserialize}; use utils::generate_id; /// permanent config to store long-term config /// used to ingest env settings /// config loaded from env variables #[derive(Deserialize, Serialize, Debug)] struct Config { helloasso_email: String, helloasso_password: String, paheko_base_url: String, paheko_client_id: String, paheko_client_secret: String, } // start user cache management use std::fs; #[derive(Serialize, Deserialize, Debug)] struct UserCache { helloasso_session: Option<helloasso::WebSession> } #[derive(Display, Debug, Error)] #[strum(serialize_all = "snake_case")] enum LoadError { XDG, Fs, FailedToParse, FailedToEncode, FailedToCreate, FailedToWrite } fn write_user_cache(cache: &UserCache) -> Result<(), LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) .map_err(|_e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.place_cache_file("session.json").map_err(|_e| { LoadError::FailedToCreate })?; let encoded_cache = serde_json::to_string(&cache).map_err(|_e| { LoadError::FailedToEncode })?; fs::write(user_cache_path, encoded_cache.as_str()).map_err(|_e| { LoadError::FailedToWrite })?; Ok(()) } fn load_user_cache() -> Result<UserCache, LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) .map_err(|_e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.get_cache_file("session.json"); if !user_cache_path.exists() { let default_cache = UserCache { helloasso_session: None }; write_user_cache(&default_cache)?; } let session_content = fs::read_to_string(user_cache_path).map_err(|_e| { LoadError::Fs })?; let cache: UserCache = serde_json::from_str(&session_content).map_err(|_e| { LoadError::FailedToParse })?; Ok(cache) } // TODO: find a better way to have the logic implemented async fn get_auth_client_from_cache( user_cache: &mut UserCache, ha_client: &mut helloasso::Client, login_payload: helloasso::LoginPayload ) -> Result<helloasso::AuthentifiedClient> { async fn login( user_cache: &mut UserCache, ha_client: &mut helloasso::Client, login_payload: helloasso::LoginPayload ) -> Result<helloasso::AuthentifiedClient> { let auth_client = ha_client.login( login_payload ).await.context("Failed to login")?; user_cache.helloasso_session = Some(auth_client.session.clone()); write_user_cache(&user_cache).expect("unable to write user cache"); println!("Logged in and wrote token to cache"); Ok(auth_client) } match &user_cache.helloasso_session { Some(cached_session) => { let auth_client = ha_client.authentified_client(cached_session.clone()); if !auth_client.verify_auth().await? { println!("Need to relog, token invalid"); return login(user_cache, ha_client, login_payload).await } println!("Used anterior token"); Ok(auth_client) }, None => { println!("First time login"); login(user_cache, ha_client, login_payload).await } } } /// rust how to access inner enum value #[derive(Debug, PartialEq, Clone, Copy)] enum HelloassoCustomFieldType { Email, Address, PostalCode, City, Phone, Job, Skills, Birthday, LinkedUserFirstName } impl TryFrom<&str> for HelloassoCustomFieldType { type Error = (); fn try_from(subject: &str) -> Result<Self, Self::Error> { match subject { "Prénom conjoint" => Ok(HelloassoCustomFieldType::LinkedUserFirstName), "ADRESSE" => Ok(HelloassoCustomFieldType::Address), "CODE POSTAL" => Ok(HelloassoCustomFieldType::PostalCode), "VILLE" => Ok(HelloassoCustomFieldType::City), "EMAIL" => Ok(HelloassoCustomFieldType::Email), "PROFESSION" => Ok(HelloassoCustomFieldType::Job), "TÉLÉPHONE" => Ok(HelloassoCustomFieldType::Phone), "DATE DE NAISSANCE" => Ok(HelloassoCustomFieldType::Birthday), "CENTRE D'INTÉRÊTS / COMPÉTENCES" => Ok(HelloassoCustomFieldType::Skills), _ => Err(()) } } } fn read_custom_field(form_answer: &helloasso::FormAnswer, custom_field: HelloassoCustomFieldType) -> Option<String> { // FIXME: compute the type directly at deserialization with serde form_answer.custom_fields.iter() .find(|f| HelloassoCustomFieldType::try_from(f.name.as_str()) == Ok(custom_field)) .map(|cf| cf.answer.clone()) } fn parse_normalize_phone(phone_number_opt: Option<String>) -> Option<String> { let number_raw = phone_number_opt?; let parsed = match phonenumber::parse(Some(phonenumber::country::Id::FR), number_raw) { Ok(r) => { r }, Err(_e) => { return None; } }; Some(parsed.to_string()) } fn normalize_str(subject: String) -> String { subject.trim().replace("\n", ";").to_string() } /// remove year precision to comply with GDPR eu rules fn parse_and_get_birthday_year(raw_date: String) -> Option<u32> { let d_res = NaiveDate::parse_from_str(raw_date.trim(), "%d/%m/%Y"); let d = d_res.ok()?; d.year().try_into().ok() } async fn launch_adapter() -> Result<()> { dotenvy::dotenv()?; let config: Config = envy::from_env().context("Failed to load env vars")?; 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); let paheko_credentials = paheko::Credentials { client_id: config.paheko_client_id, client_secret: config.paheko_client_secret }; let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?; let mut ha_client: helloasso::Client = Default::default(); let login_payload = helloasso::LoginPayload { email: config.helloasso_email, password: config.helloasso_password }; let auth_client: helloasso::AuthentifiedClient = get_auth_client_from_cache(&mut user_cache, &mut ha_client, login_payload).await?; // FIXME: make it configurable let ha_org_slug = "l-etoile-de-bethleem-association-des-amis-de-la-chapelle-de-bethleem-d-aubevoye"; // FIXME: make it configurable let pk_target_campaign_name = "Cotisation 2023-2024"; let ha_form_name = "2023-2024"; let org: helloasso::Organization = auth_client.organization(ha_org_slug); let answers = org.get_form_answers(ha_form_name).await?; // dbg!(&answers); println!("Got {} answers to the membership form. Processing...", &answers.len()); let mut pk_memberships: Vec<paheko::Membership> = vec![]; use email_address::*; fn choose_email(answer: &helloasso::FormAnswer) -> Option<String> { read_custom_field(answer, HelloassoCustomFieldType::Email) .and_then(|x| { if !EmailAddress::is_valid(&x) { None } else { Some(x) } }) .or(Some(answer.payer_user.email.clone())) } // 1. get summary of existing paheko users let mut existing_users = paheko_client.get_users().await.context("Get users")?; // 2. get summary of transactions for that year let existing_transactions = paheko_client.get_transactions(1).await.context("Get transactions")?; // query paheko to get top ids // IMPORTANT: this mean that while the script is running, there must be NO mutations to the // users and services_users table on the paheko side let mut pk_next_user_id = paheko_client.get_next_id("users") .await.context("Get paheko users next id")?; let mut pk_next_user_service_id = paheko_client.get_next_id("services_users") .await.context("Get paheko services_users next id")?; for answer in answers { eprintln!("Processing answer:"); let email = choose_email(&answer); eprintln!(" email: {:?}", email); // list of users involved in this answer let mut pk_users_summaries: Vec<paheko::UserSummary> = vec![]; let mut pk_user_service_registrations: Vec<paheko::UserServiceRegistration> = vec![]; let mut pk_user = paheko::User { id: utils::Id(0), first_name: Some(normalize_str(answer.user.first_name.clone())), last_name: normalize_str(answer.user.last_name.clone()), email, phone: parse_normalize_phone(read_custom_field(&answer, HelloassoCustomFieldType::Phone)), skills: read_custom_field(&answer, HelloassoCustomFieldType::Skills).map(normalize_str), address: read_custom_field(&answer, HelloassoCustomFieldType::Address) .map(normalize_str) .expect("Expected ha answer to have address"), postal_code: read_custom_field(&answer, HelloassoCustomFieldType::PostalCode) .expect("Expected ha answer to have postalcode"), city: read_custom_field(&answer, HelloassoCustomFieldType::City) .map(normalize_str) .expect("Expected ha answer to have city"), country: answer.payer_user.country.clone().trim()[..=1].to_string(), // we expect country code ISO 3166-1 alpha-2 job: read_custom_field(&answer, HelloassoCustomFieldType::Job).map(normalize_str), birth_year: read_custom_field(&answer, HelloassoCustomFieldType::Birthday).and_then(parse_and_get_birthday_year), register_time: answer.order.inception_time, }; // apply custom user override // this particular answer had duplicate phone and email from another answer if answer.id == 64756582 { pk_user.email = None; pk_user.phone = None; } // check for existing transactions if existing_transactions.iter().any( |summary| summary.reference == format!("HA/{}", answer.id) ) { eprintln!(" skipped: existing transaction found"); continue; } let existing_user_opt = existing_users.iter().find(|user| user.email == pk_user.email).cloned(); // check for existing paheko user, or create paheko user let pk_user_summary = match existing_user_opt.clone() { Some(user) => user, None => { let c = paheko_client.create_user( &pk_user, pk_next_user_id ).await.context("Expected to create paheko user")?; eprintln!(" Created paheko user"); pk_next_user_id += 1; existing_users.push(c.clone()); c } }; pk_users_summaries.push(pk_user_summary); let mut pk_membership = paheko::Membership { id: generate_id(), campaign_name: pk_target_campaign_name.to_string(), // FIXME: handle errors mode_name: serde_json::to_value(answer.mode.clone()) .unwrap().as_str().unwrap().to_string(), start_time: answer.order.inception_time, end_time: DateTime::<Utc>::from_naive_utc_and_offset( NaiveDate::from_ymd_opt(2024, 12, 31).unwrap().and_hms_opt(23, 59, 59).unwrap(), Utc ), payed_amount: f64::from(answer.amount)/100.0, users: vec![pk_user.id.clone()], external_references: paheko::ExternalReferences { helloasso_refs: paheko::HelloassoReferences { answer_id: answer.id, order_id: answer.order.id } } }; // add activity for first member let user_registration = paheko_client.register_user_to_service( pk_users_summaries.get(0).unwrap(), &pk_membership, pk_next_user_service_id ).await.context("Expected to register user activity to paheko")?; pk_user_service_registrations.push(user_registration); pk_next_user_service_id += 1; eprintln!(" Created paheko activity registration"); // then create optional linked user if answer.mode == helloasso::MembershipMode::Couple { let mut second_pk_user = pk_user.clone(); second_pk_user.id = utils::Id(0); second_pk_user.email = None; second_pk_user.phone = None; second_pk_user.skills = None; second_pk_user.job = None; second_pk_user.birth_year = None; // add first_name match read_custom_field(&answer, HelloassoCustomFieldType::LinkedUserFirstName) { Some(name) => { second_pk_user.first_name = Some(name); }, None => { second_pk_user.first_name = None; eprintln!("Warn: Got a user with Couple mode but no additional name given!") } } if existing_user_opt.is_none() { let second_pk_user_summary = paheko_client.create_user(&second_pk_user, pk_next_user_id) .await.context("Expected to create second paheko user")?; eprintln!(" Created conjoint paheko user"); pk_users_summaries.push(second_pk_user_summary); pk_next_user_id += 1; // create activity of second user let user_registration = paheko_client.register_user_to_service( pk_users_summaries.get(1).unwrap(), &pk_membership, pk_next_user_service_id ).await.context("Registering service to second paheko server")?; pk_user_service_registrations.push(user_registration); pk_next_user_service_id += 1; eprintln!(" Created paheko activity registration for conjoint user"); } // TODO: get existing linked user from previous year pk_membership.users.push(second_pk_user.id.clone()); } // add transaction let transaction = paheko::SimpleTransaction { // TODO: make the label template configurable label: format!("Adhésion {:?} via HelloAsso", pk_membership.mode_name), amount: pk_membership.payed_amount, reference: format!("HA/{}", pk_membership.external_references.helloasso_refs.answer_id), // TODO: make these field configurable credit_account_code: "756".to_string(), // cotisations account debit_account_code: "512HA".to_string(), // helloasso account inception_time: answer.order.inception_time, kind: paheko::TransactionKind::Revenue, linked_users: pk_users_summaries.iter().map(|x| x.id.clone()).collect(), // this depend on a patch to paheko API code to work 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"); eprintln!(" Created paheko transaction"); pk_memberships.push(pk_membership); } eprintln!(); eprintln!("Done."); Ok(()) } #[tokio::main] async fn main() { // TODO: add argument parser to have handle config file let res = launch_adapter().await; match res { Err(err) => { eprintln!("Program failed, details bellow"); eprintln!("{:?}", err); }, Ok(()) => { eprintln!("Program done"); } } }