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
|
- 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';"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
276
src/main.rs
276
src/main.rs
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue