helloasso-paheko-adapter/src/sync_paheko.rs

264 lines
11 KiB
Rust

use crate::paheko;
use crate::paheko::AccountingYear;
use crate::{
Config, UserCache,
};
use crate::utils::{generate_id, normalize_first_name, normalize_last_name};
use anyhow::{Context, Result};
use chrono::prelude::{NaiveDate, DateTime, Utc};
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: f64,
donation_amount: f64,
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();
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
struct Stats {
subscriptions_created: u32,
users_created: u32
}
let mut stats = Stats { subscriptions_created: 0, users_created: 0 };
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_inp in &answers {
let mut answer = answer_inp.clone();
answer.first_name = answer.first_name.map(normalize_first_name);
answer.last_name = normalize_last_name(answer.last_name);
eprintln!("Processing answer:");
eprintln!(" name: {} {}", &answer.last_name, answer.first_name.clone().unwrap_or("".to_string()));
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
// TODO: check user with fuzzing first name and last name
let existing_user_opt = existing_users
.iter().find(|user| user.first_name == answer.first_name && user.last_name == answer.last_name)
.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) => {
eprintln!(" Found existing paheko user by name.");
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());
stats.users_created += 1;
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: answer.subscription_amount,
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: match get_accounting_year_for_time(&accounting_years, &answer.inception_time) {
None => {
eprintln!("Cannot find an accounting year on paheko that include the inception date {:?} given", &answer.inception_time);
panic!();
},
Some(s) => s
}.id.clone(),
// TODO: make the label template configurable
label: format!("{} {:?} via {}", pk_membership.service_name, 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(),
// the linked_services, depend on a patch to paheko API code to work (see https://forge.lefuturiste.fr/mbess/paheko-fork/commit/a4fdd816112f51db23a2b02ac160b0513a5b09c5)
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");
stats.subscriptions_created += 1;
// TODO: handle donation amount
pk_memberships.push(pk_membership);
}
eprintln!("{via_name} sync done.");
eprintln!("{} subs created; {} users created", stats.subscriptions_created, stats.users_created);
Ok(())
}