diff --git a/TODO.md b/TODO.md index 64dc6b9..bd4ef1d 100644 --- a/TODO.md +++ b/TODO.md @@ -31,3 +31,12 @@ TODO: - 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 + +## 2024-01-11 + +- automatically find the tresorerie exercice based on the date of the transaction + + +query all subscriptions of user byu service label + +curl -u $PAHEKO_CLIENT_ID:$PAHEKO_CLIENT_SECRET http://localhost:8082/api/sql -d "SELECT su.id_user,su.date FROM services_users AS su JOIN services AS s ON su.id_service = s.id WHERE s.label = 'Cotisation 2023-2024';" diff --git a/src/helloasso.rs b/src/helloasso.rs index 3acf4ee..9edf796 100644 --- a/src/helloasso.rs +++ b/src/helloasso.rs @@ -289,19 +289,6 @@ struct Organization { slug: String } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[fully_pub] -enum MembershipMode { - #[serde(rename = "Individuel")] - Individual, - #[serde(rename = "Couple")] - Couple, - #[serde(rename = "Individuel bienfaiteur")] - BenefactorIndividual, - #[serde(rename = "Couple bienfaiteur")] - BenefactorCouple, -} - #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[fully_pub] @@ -318,7 +305,7 @@ struct FormAnswer { amount: u32, #[serde(rename = "name")] - mode: MembershipMode, + mode: String, #[serde(rename = "payer")] payer_user: PayerUserDetails, diff --git a/src/main.rs b/src/main.rs index d8bf772..acda87e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,25 @@ +#![feature(slice_group_by)] + mod utils; mod paheko; mod helloasso; +mod sync_helloasso; +mod sync_csv; +mod sync_paheko; use thiserror::Error; use anyhow::{Context, Result, anyhow}; -use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; +use chrono::prelude::{NaiveDate, Datelike}; use strum::Display; use serde::{Serialize, Deserialize}; -use utils::generate_id; use url::Url; +use fully_pub::fully_pub; /// permanent config to store long-term config /// used to ingest env settings /// config loaded from env variables #[derive(Deserialize, Serialize, Debug)] +#[fully_pub] struct Config { helloasso_proxy: Option, helloasso_email: String, @@ -28,13 +34,14 @@ struct Config { paheko_client_secret: String, paheko_target_activity_name: String, - paheko_accounting_year_id: u64, + // paheko_accounting_year_id: u64, } // start user cache management use std::fs; #[derive(Serialize, Deserialize, Debug)] +#[fully_pub] struct UserCache { helloasso_session: Option } @@ -118,47 +125,6 @@ async fn get_auth_client_from_cache( } } - -/// 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 { - 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 { - // 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) -> Option { let number_raw = phone_number_opt?; @@ -186,9 +152,9 @@ fn parse_and_get_birthday_year(raw_date: String) -> Option { d.year().try_into().ok() } -fn get_proxy_from_url(proxy_url: Option) -> Result> { +fn get_proxy_from_url(proxy_url: &Option) -> Result> { Ok(match proxy_url { - Some(p) => Some(reqwest::Proxy::all(&p) + Some(p) => Some(reqwest::Proxy::all(p) .context("Expected to build Proxy from paheko_proxy config value")?), None => None }) @@ -206,226 +172,18 @@ async fn launch_adapter() -> Result<()> { } 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)?, + 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, - client_secret: config.paheko_client_secret + client_id: config.paheko_client_id.clone(), + client_secret: config.paheko_client_secret.clone() }; let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?; - 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, - password: config.helloasso_password - }; - let auth_client: helloasso::AuthentifiedClient = - get_auth_client_from_cache(&mut user_cache, &mut ha_client, login_payload).await?; - - let org = auth_client.organization(&config.helloasso_organization_slug); - let answers = org.get_form_answers(&config.helloasso_form_name).await?; - - // dbg!(&answers); - println!("Got {} answers to the membership form. Processing...", &answers.len()); - - let mut pk_memberships: Vec = vec![]; - - use email_address::*; - fn choose_email(answer: &helloasso::FormAnswer) -> Option { - 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 = vec![]; - let mut pk_user_service_registrations: Vec = 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: config.paheko_target_activity_name.clone(), - // 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::::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 { - 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, - 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."); + sync_helloasso::sync_helloasso(&paheko_client, &config, &mut user_cache).await?; + // sync_csv::sync(&paheko_client, &config, &mut user_cache).await?; Ok(()) } diff --git a/src/paheko.rs b/src/paheko.rs index ce362e5..fdd9c22 100644 --- a/src/paheko.rs +++ b/src/paheko.rs @@ -4,7 +4,11 @@ use serde::{Serialize, Deserialize}; use fully_pub::fully_pub; use crate::utils::Id; use chrono::prelude::{DateTime, Utc}; +use chrono::NaiveDate; use thiserror::Error; +use crate::sync_paheko::GeneralizedAnswer; +use crate::utils::deserialize_date; + #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] @@ -56,13 +60,12 @@ struct UserSummary { #[fully_pub] struct Membership { id: Id, - users: Vec, - campaign_name: String, + users_ids: Vec, + service_name: String, mode_name: String, start_time: DateTime, end_time: DateTime, - payed_amount: f64, - external_references: ExternalReferences, + payed_amount: f64 } #[derive(Debug, Clone)] @@ -96,6 +99,21 @@ struct SimpleTransaction { accounting_year: Id } + +#[derive(Debug, Clone, Deserialize)] +#[fully_pub] +struct AccountingYear { + id: Id, + label: String, + closed: u32, + + #[serde(deserialize_with = "deserialize_date", rename="start_date")] + start_date: NaiveDate, + #[serde(deserialize_with = "deserialize_date", rename="end_date")] + end_date: NaiveDate +} + + #[derive(Error, Debug)] enum APIClientError { #[error("Received non-normal status code from API")] @@ -312,7 +330,65 @@ impl AuthentifiedClient { Ok(serde_json::from_value(val.results)?) } - pub async fn create_user(&self, user: &User, next_id: u64) + pub async fn get_accounting_years(&self) + -> Result> + { + let path = self.config.base_url.join("accounting/years")?; + let res = self.client + .get(path) + .send().await?; + if res.status() != 200 { + return Err(APIClientError::InvalidStatusCode.into()); + } + res.json().await.context("Get accounting years") + } + + /// get a list of membership + pub async fn get_service_subscriptions(&self, service_name: &str) + -> Result> + { + let query: String = format!(r#" + SELECT su.id,su.id_user,su.date,su.expiry_date FROM services_users AS su JOIN services AS s ON su.id_service = s.id WHERE s.label = '{}'; + "#, service_name); + + let val = self.sql_query(query).await.context("Fetching service subscriptions")?; + + #[derive(Deserialize)] + struct Row { + id: u64, + id_user: u64, + #[serde(deserialize_with = "deserialize_date")] + date: NaiveDate, + #[serde(deserialize_with = "deserialize_date")] + expiry_date: NaiveDate + } + let intermidiate: Vec = serde_json::from_value(val.results)?; + // regroup the row with the same id + Ok(intermidiate + .group_by(|a,b| a.id == b.id) + .map(|rows| { + let base = rows.first().unwrap(); + + Membership { + id: Id(base.id), + mode_name: service_name.to_string(), + service_name: "".to_string(), + start_time: DateTime::::from_naive_utc_and_offset( + base.date.and_hms_opt(0, 0, 0).unwrap(), + Utc + ), + end_time: DateTime::::from_naive_utc_and_offset( + base.expiry_date.and_hms_opt(0, 0, 0).unwrap(), + Utc + ), + users_ids: rows.iter().map(|x| Id(x.id_user)).collect(), + payed_amount: 0.0 + } + }).collect() + ) + } + + pub async fn create_user(&self, user: &GeneralizedAnswer, next_id: u64) -> Result { // single-user import @@ -323,20 +399,20 @@ impl AuthentifiedClient { csv_content.push_str("numero,nom,last_name,adresse,code_postal,ville,pays,telephone,email,annee_naissance,profession,interets,lettre_infos,date_inscription\n"); csv_content.push_str( format!("{},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{},{:?},{:?},{},{}\n", - "".to_string(), + next_id.to_string(), u.first_name.clone().unwrap_or("".to_string()), u.last_name.clone(), u.address, u.postal_code, u.city, u.country, - u.phone.unwrap_or("".to_string()), + u.phone.clone().unwrap_or("".to_string()), u.email.clone().unwrap_or("".to_string()), u.birth_year.map(|x| format!("{}", x)).unwrap_or("".to_string()), - u.job.unwrap_or("".to_string()), - u.skills.unwrap_or("".to_string()), + u.job.clone().unwrap_or("".to_string()), + u.skills.clone().unwrap_or("".to_string()), 1, - user.register_time.format("%d/%m/%Y") + user.inception_time.format("%d/%m/%Y") ).as_str()); use reqwest::multipart::Form; @@ -379,7 +455,7 @@ impl AuthentifiedClient { csv_content.push_str( format!("{},{:?},{:?},{:?},{:?},{:?},{:?}\n", u.id, - user_membership.campaign_name, + user_membership.service_name, user_membership.mode_name, user_membership.start_time.format("%d/%m/%Y").to_string(), user_membership.end_time.format("%d/%m/%Y").to_string(), diff --git a/src/sync_helloasso.rs b/src/sync_helloasso.rs new file mode 100644 index 0000000..53fee44 --- /dev/null +++ b/src/sync_helloasso.rs @@ -0,0 +1,138 @@ +use crate::helloasso; +use crate::paheko; +use crate::{ + Config, UserCache, + get_proxy_from_url, get_auth_client_from_cache, parse_and_get_birthday_year, parse_normalize_phone, normalize_str +}; +use crate::sync_paheko::{GeneralizedAnswer, sync_paheko}; + +use anyhow::Result; +use url::Url; + +/// 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 { + 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 { + // 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()) +} + + +pub async fn sync_helloasso(paheko_client: &paheko::AuthentifiedClient, config: &Config, user_cache: &mut UserCache) -> Result<()> { + 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.clone(), + password: config.helloasso_password.clone() + }; + let auth_client: helloasso::AuthentifiedClient = + get_auth_client_from_cache(user_cache, &mut ha_client, login_payload).await?; + + let org = auth_client.organization(&config.helloasso_organization_slug); + let answers = org.get_form_answers(&config.helloasso_form_name).await?; + + println!("Got {} answers to the membership form. Processing...", &answers.len()); + + use email_address::*; + fn choose_email(answer: &helloasso::FormAnswer) -> Option { + 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())) + } + + let mut generalized_answers: Vec = vec![]; + for answer in answers { + // eprintln!("Processing answer:"); + let email = choose_email(&answer); + // eprintln!(" email: {:?}", email); + + let mut generalized_answer = GeneralizedAnswer { + 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), + inception_time: answer.order.inception_time, + reference: format!("HA/{}", answer.id), + donation_amount: 0, + subscription_amount: answer.amount, + membership_mode: serde_json::from_value(serde_json::Value::String(answer.mode.clone())) + .expect("Expected a membership mode to be valid"), + linked_user_first_name: read_custom_field(&answer, HelloassoCustomFieldType::LinkedUserFirstName) + }; + + // apply custom user override + // this particular answer had duplicate phone and email from another answer + if answer.id == 64756582 { + generalized_answer.email = None; + generalized_answer.phone = None; + } + + generalized_answers.push(generalized_answer); + } + println!("Generated GeneralizedAnswers"); + sync_paheko( + paheko_client, + config, + user_cache, + generalized_answers, + "512", + "HelloAsso" + ).await +} + + diff --git a/src/sync_paheko.rs b/src/sync_paheko.rs new file mode 100644 index 0000000..65384c2 --- /dev/null +++ b/src/sync_paheko.rs @@ -0,0 +1,238 @@ +use crate::paheko; +use crate::paheko::AccountingYear; +use crate::{ + Config, UserCache, +}; +use crate::utils; + +use anyhow::{Context, Result}; +use chrono::prelude::{NaiveDate, DateTime, Utc}; +use crate::utils::generate_id; +use fully_pub::fully_pub; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[fully_pub] +enum MembershipMode { + #[serde(rename = "Individuel")] + Individual, + #[serde(rename = "Couple")] + Couple, + #[serde(rename = "Individuel bienfaiteur")] + BenefactorIndividual, + #[serde(rename = "Couple bienfaiteur")] + BenefactorCouple, +} + +#[derive(Debug, Clone)] +#[fully_pub] +struct GeneralizedAnswer { + // TODO: users are unique via their first and last name, instead of emails + first_name: Option, + last_name: String, + email: Option, + + phone: Option, + address: String, + city: String, + postal_code: String, + country: String, + skills: Option, + job: Option, + birth_year: Option, + + membership_mode: MembershipMode, + inception_time: DateTime, + subscription_amount: u32, + donation_amount: u32, + reference: String, + + linked_user_first_name: Option +} + +fn get_accounting_year_for_time<'a>(accounting_years: &'a Vec, time: &'a DateTime) -> Option<&'a AccountingYear> { + let date_ref = time.date_naive().clone(); + dbg!("{:?}", date_ref); + accounting_years.iter().find(|year| year.start_date < date_ref && date_ref < year.end_date) +} + +pub async fn sync_paheko( + paheko_client: &paheko::AuthentifiedClient, + config: &Config, + user_cache: &mut UserCache, + answers: Vec, + debit_account_code: &str, + via_name: &str +) -> Result<()> { + // FIXME: search existing paheko users using the first name and last name, some ppl don't have + // emails + + let mut pk_memberships: Vec = vec![]; + + let accounting_years = paheko_client.get_accounting_years().await.context("Get acc years")?; + + // 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 + // TODO: also get for the year n-1 + let existing_transactions = paheko_client.get_transactions(1).await.context("Get transactions")?; + // 3. get summary of services_users for that year + let existing_subscriptions = paheko_client.get_service_subscriptions(&config.paheko_target_activity_name) + .await.context("Get existing paheko subscriptions to the target activity")?; + + // 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:"); + eprintln!(" email: {:?}", answer.email); + + // list of users involved in this answer + let mut pk_users_summaries: Vec = vec![]; + let mut pk_user_service_registrations: Vec = vec![]; + + // check for existing user in paheko by email + let existing_user_opt = existing_users.iter().find(|user| user.email == answer.email).cloned(); + + // check for existing transactions + if existing_transactions.iter().any( + |summary| summary.reference == answer.reference + ) { + eprintln!(" skipped: existing transaction found"); + continue; + } + + // dbg!(&existing_subscriptions); + let pk_user_summary = match existing_user_opt.clone() { + Some(user) => { + user + }, + None => { + // create paheko user + let c = paheko_client.create_user( + &answer, 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.clone()); + + // check if the user is already subscribed to the target activity + if + existing_user_opt.is_some() && + existing_subscriptions.iter() + .find(|membership| membership.users_ids + .iter().find(|i| **i == pk_user_summary.id).is_some() + ) + .is_some() + { + eprintln!(" skipped: user is already subscribed to this activity"); + continue; + } + + let mut pk_membership = paheko::Membership { + id: generate_id(), + service_name: config.paheko_target_activity_name.clone(), + // FIXME: handle errors when mode is invalid + mode_name: serde_json::to_value(answer.membership_mode.clone()) + .unwrap().as_str().unwrap().to_string(), + start_time: answer.inception_time, + end_time: + DateTime::::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.subscription_amount)/100.0, + users_ids: vec![pk_user_summary.id.clone()] + }; + + // add activity for first member + // TODO: check if activity already exists + 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.membership_mode == MembershipMode::Couple { + let mut second_answer = answer.clone(); + second_answer.email = None; + second_answer.phone = None; + second_answer.skills = None; + second_answer.job = None; + second_answer.birth_year = None; + + // add first_name + match answer.linked_user_first_name { + Some(name) => { + second_answer.first_name = Some(name); + }, + None => { + second_answer.first_name = None; + eprintln!("Warn: Got a user with Couple mode but no additional name given!") + } + } + + if existing_user_opt.is_none() { + // only create the linked user in paheko, if the first user was also created + let second_pk_user_summary = paheko_client.create_user(&second_answer, 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.clone()); + 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(), // pass user, for the id + &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"); + + pk_membership.users_ids.push(second_pk_user_summary.id) + } + // FIXME: reuse a previous user + // TODO: get existing linked user from previous year + + } + + // add transaction + let transaction = paheko::SimpleTransaction { + accounting_year: get_accounting_year_for_time(&accounting_years, &answer.inception_time) + .expect("Cannot find an accounting year that match the date on paheko").id.clone(), + // TODO: make the label template configurable + label: format!("Adhésion {:?} via {}", pk_membership.mode_name, via_name), + amount: pk_membership.payed_amount, + reference: answer.reference, + // TODO: make these field configurable + credit_account_code: "756".to_string(), // cotisations account + debit_account_code: debit_account_code.to_string(), // helloasso account + inception_time: answer.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!("{via_name} sync done."); + Ok(()) +} diff --git a/src/utils.rs b/src/utils.rs index 81772e4..456d1d6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,7 @@ use serde::{Serialize, Deserialize, Deserializer}; use std::fmt; use rand::{thread_rng, Rng}; -use chrono::prelude::{DateTime, Utc}; +use chrono::prelude::{DateTime, Utc, NaiveDate}; /// ID #[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash)] @@ -23,6 +23,7 @@ pub fn generate_id() -> Id { } +/// https://serde.rs/field-attrs.html pub fn deserialize_datetime<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -32,3 +33,13 @@ where .map_err(serde::de::Error::custom) .map(|dt| dt.with_timezone(&Utc)) } + +pub fn deserialize_date<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + NaiveDate::parse_from_str(&s, "%Y-%m-%d") + .map_err(serde::de::Error::custom) +} +