feat: generalize sync source

This commit is contained in:
Matthieu Bessat 2024-01-13 17:51:06 +01:00
parent 91f74a8a34
commit b658fcd69e
7 changed files with 502 additions and 285 deletions

View file

@ -31,3 +31,12 @@ TODO:
- handle name of the service or service fee not found - 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 - 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';"

View file

@ -289,19 +289,6 @@ struct Organization {
slug: String 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)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[fully_pub] #[fully_pub]
@ -318,7 +305,7 @@ struct FormAnswer {
amount: u32, amount: u32,
#[serde(rename = "name")] #[serde(rename = "name")]
mode: MembershipMode, mode: String,
#[serde(rename = "payer")] #[serde(rename = "payer")]
payer_user: PayerUserDetails, payer_user: PayerUserDetails,

View file

@ -1,19 +1,25 @@
#![feature(slice_group_by)]
mod utils; mod utils;
mod paheko; mod paheko;
mod helloasso; mod helloasso;
mod sync_helloasso;
mod sync_csv;
mod sync_paheko;
use thiserror::Error; use thiserror::Error;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; use chrono::prelude::{NaiveDate, Datelike};
use strum::Display; use strum::Display;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use utils::generate_id;
use url::Url; use url::Url;
use fully_pub::fully_pub;
/// permanent config to store long-term config /// permanent config to store long-term config
/// used to ingest env settings /// used to ingest env settings
/// config loaded from env variables /// config loaded from env variables
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
#[fully_pub]
struct Config { struct Config {
helloasso_proxy: Option<String>, helloasso_proxy: Option<String>,
helloasso_email: String, helloasso_email: String,
@ -28,13 +34,14 @@ struct Config {
paheko_client_secret: String, paheko_client_secret: String,
paheko_target_activity_name: String, paheko_target_activity_name: String,
paheko_accounting_year_id: u64, // paheko_accounting_year_id: u64,
} }
// start user cache management // start user cache management
use std::fs; use std::fs;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[fully_pub]
struct UserCache { struct UserCache {
helloasso_session: Option<helloasso::WebSession> helloasso_session: Option<helloasso::WebSession>
} }
@ -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<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> { fn parse_normalize_phone(phone_number_opt: Option<String>) -> Option<String> {
let number_raw = phone_number_opt?; let number_raw = phone_number_opt?;
@ -186,9 +152,9 @@ fn parse_and_get_birthday_year(raw_date: String) -> Option<u32> {
d.year().try_into().ok() d.year().try_into().ok()
} }
fn get_proxy_from_url(proxy_url: Option<String>) -> Result<Option<reqwest::Proxy>> { fn get_proxy_from_url(proxy_url: &Option<String>) -> Result<Option<reqwest::Proxy>> {
Ok(match proxy_url { 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")?), .context("Expected to build Proxy from paheko_proxy config value")?),
None => None None => None
}) })
@ -206,226 +172,18 @@ async fn launch_adapter() -> Result<()> {
} }
let mut paheko_client: paheko::Client = paheko::Client::new(paheko::ClientConfig { 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"), 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() user_agent: APP_USER_AGENT.to_string()
}); });
let paheko_credentials = paheko::Credentials { let paheko_credentials = paheko::Credentials {
client_id: config.paheko_client_id, client_id: config.paheko_client_id.clone(),
client_secret: config.paheko_client_secret client_secret: config.paheko_client_secret.clone()
}; };
let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?; let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?;
let mut ha_client: helloasso::Client = helloasso::Client::new(helloasso::ClientConfig { sync_helloasso::sync_helloasso(&paheko_client, &config, &mut user_cache).await?;
base_url: Url::parse("https://api.helloasso.com/v5/") // sync_csv::sync(&paheko_client, &config, &mut user_cache).await?;
.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<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: 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::<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 {
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.");
Ok(()) Ok(())
} }

View file

@ -4,7 +4,11 @@ use serde::{Serialize, Deserialize};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use crate::utils::Id; use crate::utils::Id;
use chrono::prelude::{DateTime, Utc}; use chrono::prelude::{DateTime, Utc};
use chrono::NaiveDate;
use thiserror::Error; use thiserror::Error;
use crate::sync_paheko::GeneralizedAnswer;
use crate::utils::deserialize_date;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub] #[fully_pub]
@ -56,13 +60,12 @@ struct UserSummary {
#[fully_pub] #[fully_pub]
struct Membership { struct Membership {
id: Id, id: Id,
users: Vec<Id>, users_ids: Vec<Id>,
campaign_name: String, service_name: String,
mode_name: String, mode_name: String,
start_time: DateTime<Utc>, start_time: DateTime<Utc>,
end_time: DateTime<Utc>, end_time: DateTime<Utc>,
payed_amount: f64, payed_amount: f64
external_references: ExternalReferences,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -96,6 +99,21 @@ struct SimpleTransaction {
accounting_year: Id 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)] #[derive(Error, Debug)]
enum APIClientError { enum APIClientError {
#[error("Received non-normal status code from API")] #[error("Received non-normal status code from API")]
@ -312,7 +330,65 @@ impl AuthentifiedClient {
Ok(serde_json::from_value(val.results)?) 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<Vec<AccountingYear>>
{
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<Vec<Membership>>
{
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<Row> = 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::<Utc>::from_naive_utc_and_offset(
base.date.and_hms_opt(0, 0, 0).unwrap(),
Utc
),
end_time: DateTime::<Utc>::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<UserSummary> -> Result<UserSummary>
{ {
// single-user import // 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("numero,nom,last_name,adresse,code_postal,ville,pays,telephone,email,annee_naissance,profession,interets,lettre_infos,date_inscription\n");
csv_content.push_str( csv_content.push_str(
format!("{},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{},{:?},{:?},{},{}\n", format!("{},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{},{:?},{:?},{},{}\n",
"".to_string(), next_id.to_string(),
u.first_name.clone().unwrap_or("".to_string()), u.first_name.clone().unwrap_or("".to_string()),
u.last_name.clone(), u.last_name.clone(),
u.address, u.address,
u.postal_code, u.postal_code,
u.city, u.city,
u.country, u.country,
u.phone.unwrap_or("".to_string()), u.phone.clone().unwrap_or("".to_string()),
u.email.clone().unwrap_or("".to_string()), u.email.clone().unwrap_or("".to_string()),
u.birth_year.map(|x| format!("{}", x)).unwrap_or("".to_string()), u.birth_year.map(|x| format!("{}", x)).unwrap_or("".to_string()),
u.job.unwrap_or("".to_string()), u.job.clone().unwrap_or("".to_string()),
u.skills.unwrap_or("".to_string()), u.skills.clone().unwrap_or("".to_string()),
1, 1,
user.register_time.format("%d/%m/%Y") user.inception_time.format("%d/%m/%Y")
).as_str()); ).as_str());
use reqwest::multipart::Form; use reqwest::multipart::Form;
@ -379,7 +455,7 @@ impl AuthentifiedClient {
csv_content.push_str( csv_content.push_str(
format!("{},{:?},{:?},{:?},{:?},{:?},{:?}\n", format!("{},{:?},{:?},{:?},{:?},{:?},{:?}\n",
u.id, u.id,
user_membership.campaign_name, user_membership.service_name,
user_membership.mode_name, user_membership.mode_name,
user_membership.start_time.format("%d/%m/%Y").to_string(), user_membership.start_time.format("%d/%m/%Y").to_string(),
user_membership.end_time.format("%d/%m/%Y").to_string(), user_membership.end_time.format("%d/%m/%Y").to_string(),

138
src/sync_helloasso.rs Normal file
View file

@ -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<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())
}
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<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()))
}
let mut generalized_answers: Vec<GeneralizedAnswer> = 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
}

238
src/sync_paheko.rs Normal file
View file

@ -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<String>,
last_name: String,
email: Option<String>,
phone: Option<String>,
address: String,
city: String,
postal_code: String,
country: String,
skills: Option<String>,
job: Option<String>,
birth_year: Option<u32>,
membership_mode: MembershipMode,
inception_time: DateTime<Utc>,
subscription_amount: u32,
donation_amount: u32,
reference: String,
linked_user_first_name: Option<String>
}
fn get_accounting_year_for_time<'a>(accounting_years: &'a Vec<AccountingYear>, time: &'a DateTime<Utc>) -> 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<GeneralizedAnswer>,
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<paheko::Membership> = 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<paheko::UserSummary> = vec![];
let mut pk_user_service_registrations: Vec<paheko::UserServiceRegistration> = 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::<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.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(())
}

View file

@ -1,7 +1,7 @@
use serde::{Serialize, Deserialize, Deserializer}; use serde::{Serialize, Deserialize, Deserializer};
use std::fmt; use std::fmt;
use rand::{thread_rng, Rng}; use rand::{thread_rng, Rng};
use chrono::prelude::{DateTime, Utc}; use chrono::prelude::{DateTime, Utc, NaiveDate};
/// ID /// ID
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash)] #[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<DateTime<Utc>, D::Error> pub fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
@ -32,3 +33,13 @@ where
.map_err(serde::de::Error::custom) .map_err(serde::de::Error::custom)
.map(|dt| dt.with_timezone(&Utc)) .map(|dt| dt.with_timezone(&Utc))
} }
pub fn deserialize_date<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.map_err(serde::de::Error::custom)
}