feat: generalize sync source
This commit is contained in:
parent
91f74a8a34
commit
b658fcd69e
7 changed files with 502 additions and 285 deletions
9
TODO.md
9
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';"
|
||||
|
|
|
@ -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,
|
||||
|
|
276
src/main.rs
276
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<String>,
|
||||
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<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> {
|
||||
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()
|
||||
}
|
||||
|
||||
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 {
|
||||
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<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.");
|
||||
sync_helloasso::sync_helloasso(&paheko_client, &config, &mut user_cache).await?;
|
||||
// sync_csv::sync(&paheko_client, &config, &mut user_cache).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -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<Id>,
|
||||
campaign_name: String,
|
||||
users_ids: Vec<Id>,
|
||||
service_name: String,
|
||||
mode_name: String,
|
||||
start_time: DateTime<Utc>,
|
||||
end_time: DateTime<Utc>,
|
||||
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<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>
|
||||
{
|
||||
// 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(),
|
||||
|
|
138
src/sync_helloasso.rs
Normal file
138
src/sync_helloasso.rs
Normal 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
238
src/sync_paheko.rs
Normal 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(())
|
||||
}
|
13
src/utils.rs
13
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<DateTime<Utc>, 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<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)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue