helloasso-paheko-adapter/src/paheko.rs

425 lines
12 KiB
Rust

use anyhow::{Context, Result, anyhow};
use url::Url;
use serde::{Serialize, Deserialize};
use fully_pub::fully_pub;
use crate::utils::Id;
use chrono::prelude::{DateTime, Utc};
use thiserror::Error;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct HelloassoReferences {
answer_id: u64,
order_id: u64
// payment_id: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct ExternalReferences {
helloasso_refs: HelloassoReferences
}
/// for now we include the custom fields into the paheko user
/// we don't have time to implement user settings to change the custom fields mapping
/// for now, manual mapping
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct User {
id: Id,
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>,
register_time: DateTime<Utc>
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct UserSummary {
id: Id,
first_name: Option<String>,
last_name: String,
email: Option<String>
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct Membership {
id: Id,
users: Vec<Id>,
campaign_name: String,
mode_name: String,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
payed_amount: f64,
external_references: ExternalReferences,
}
#[derive(Debug, Clone)]
#[fully_pub]
enum TransactionKind {
Expense,
Revenue
}
impl From<TransactionKind> for String {
fn from(val: TransactionKind) -> Self {
match val {
TransactionKind::Expense => "EXPENSE".to_string(),
TransactionKind::Revenue => "REVENUE".to_string()
}
}
}
#[derive(Debug, Clone)]
#[fully_pub]
struct SimpleTransaction {
label: String,
kind: TransactionKind,
inception_time: DateTime<Utc>,
amount: f64,
credit_account_code: String,
debit_account_code: String,
reference: String,
linked_users: Vec<Id>,
linked_services: Vec<Id>
}
#[derive(Debug)]
#[fully_pub]
struct Client {
client: reqwest::Client,
base_url: Url,
}
#[derive(Error, Debug)]
enum APIClientError {
#[error("Received non-normal status code from API")]
InvalidStatusCode
}
#[derive(Debug, Clone)]
#[fully_pub]
struct Credentials {
client_id: String,
client_secret: String
}
impl Default for Client {
fn default() -> Self {
Client {
client: Client::get_base_client_builder()
.build()
.expect("Expected reqwest client to be built"),
base_url: Url::parse("https://paheko.etoiledebethleem.fr/api/") // the traling slash is important
.expect("Expected valid paheko API base URL")
}
}
}
use base64_light::base64_encode;
fn build_auth_headers(credentials: &Credentials) -> reqwest::header::HeaderMap {
let mut login_headers = reqwest::header::HeaderMap::new();
login_headers.insert(
"Authorization",
format!("Basic {}", &base64_encode(
&format!("{}:{}", &credentials.client_id, &credentials.client_secret)
)).parse().expect("Header value to be OK")
);
login_headers
}
impl Client {
pub fn new(base_url: String) -> Client {
Client {
client: Client::get_base_client_builder()
.build()
.expect("Expected reqwest client to be built"),
base_url: Url::parse(&base_url) // the traling slash is important
.expect("Expected valid paheko API base URL")
}
}
fn get_base_client_builder() -> reqwest::ClientBuilder {
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert("Accept", "application/json".parse().unwrap());
let proxy = reqwest::Proxy::http("http://localhost:8998").unwrap();
reqwest::Client::builder()
.proxy(proxy)
.default_headers(default_headers)
}
pub async fn login(&mut self, credentials: Credentials) -> Result<AuthentifiedClient> {
let hypothetic_client = self.authentified_client(credentials);
let query: String = r#"
SELECT key,value FROM config WHERE key="org_name"
"#.to_string();
match hypothetic_client.sql_query(query).await {
Ok(_value) => {
Ok(hypothetic_client)
},
Err(err) => {
Err(anyhow!("Failed to authenticate: Credentials provided are invalids, {:?}", err))
}
}
}
pub fn authentified_client(&self, credentials: Credentials) -> AuthentifiedClient {
AuthentifiedClient::new(self.base_url.clone(), credentials)
}
}
#[derive(Debug, Clone)]
pub struct AuthentifiedClient {
credentials: Credentials,
client: reqwest::Client,
base_url: Url
}
// SELECT id,nom AS first_name,last_name,email,external_custom_data FROM users LIMIT 5;
#[derive(Debug, Deserialize)]
#[fully_pub]
struct SimpleUser {
id: u32,
first_name: String,
last_name: String,
email: Option<String>,
external_custom_data: Option<String>
}
#[derive(Debug, Deserialize)]
#[fully_pub]
struct SqlQueryOutput {
count: u64,
results: serde_json::Value
}
#[derive(Debug, Deserialize)]
#[fully_pub]
struct TransactionSummary {
id: u64,
reference: String
}
#[derive(Debug)]
#[fully_pub]
struct UserServiceRegistration {
id: Id
}
impl AuthentifiedClient {
pub fn new(base_url: Url, credentials: Credentials) -> Self {
AuthentifiedClient {
client: Client::get_base_client_builder()
.default_headers(build_auth_headers(&credentials))
.build()
.expect("Expect client to be built"),
credentials,
base_url
}
}
pub async fn sql_query(&self, query: String) -> Result<SqlQueryOutput> {
#[derive(Serialize)]
struct Payload {
sql: String
}
let payload = Payload { sql: query };
let path = self.base_url.join("sql")?;
let res = self.client
.post(path)
.json(&payload)
.send().await?;
if res.status() != 200 {
dbg!(res);
return Err(APIClientError::InvalidStatusCode.into());
}
res.json().await.context("Sql query")
}
pub async fn get_users(&self) -> Result<Vec<UserSummary>> {
let query: String = r#"
SELECT id,nom AS first_name,last_name,email FROM users;
"#.to_string();
let users_val = self.sql_query(query).await.context("Fetching users")?;
Ok(serde_json::from_value(users_val.results)?)
}
pub async fn get_next_id(&self, table_name: &str) -> Result<u64> {
let query: String = format!(r#"
SELECT id FROM {} ORDER BY id DESC LIMIT 1
"#, table_name).to_string();
let data = self.sql_query(query).await.context("Fetching next id from table")?;
#[derive(Deserialize)]
struct Entry {
id: u64
}
let ids: Vec<Entry> = serde_json::from_value(data.results)?;
Ok(ids.get(0).map(|x| x.id).unwrap_or(1)+1)
}
pub async fn get_transactions(&self, id_year: u32)
-> Result<Vec<TransactionSummary>>
{
let query: String = format!(r#"
SELECT id,reference FROM acc_transactions WHERE id_year={} AND reference LIKE 'HA/%';
"#, id_year).to_string();
let val = self.sql_query(query).await.context("Fetching transactions")?;
Ok(serde_json::from_value(val.results)?)
}
pub async fn create_user(&self, user: &User, next_id: u64)
-> Result<UserSummary>
{
// single-user import
// create virtual file
let u = user.clone();
let mut csv_content: String = String::new();
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(),
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.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()),
1,
user.register_time.format("%d/%m/%Y")
).as_str());
use reqwest::multipart::Form;
use reqwest::multipart::Part;
let part = Part::text(csv_content).file_name("file");
let form = Form::new()
.part("file", part);
let res = self.client
.post(self.base_url.join("user/import/")?)
.multipart(form)
.send().await?;
if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into());
}
Ok(
UserSummary {
id: Id(next_id),
first_name: u.first_name,
last_name: u.last_name,
email: u.email
}
)
}
pub async fn register_user_to_service(&self, user: &UserSummary, user_membership: &Membership, next_id: u64)
-> Result<UserServiceRegistration>
{
// single-user import
// create virtual file
let u = user.clone();
let mut csv_content: String = String::new();
csv_content.push_str(
r#""Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?""#);
csv_content.push('\n');
csv_content.push_str(
format!("{},{:?},{:?},{:?},{:?},{:?},{:?}\n",
u.id,
user_membership.campaign_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(),
format!("{}", user_membership.payed_amount),
"Oui"
).as_str());
use reqwest::multipart::Form;
use reqwest::multipart::Part;
let part = Part::text(csv_content).file_name("file");
let form = Form::new()
.part("file", part);
let res = self.client
.post(self.base_url.join("services/subscriptions/import")?)
.multipart(form)
.send().await?;
if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into());
}
Ok(UserServiceRegistration {
id: Id(next_id)
})
}
pub async fn register_transaction(&self, transaction: SimpleTransaction)
-> Result<()>
{
use reqwest::multipart::Form;
let mut form = Form::new()
.text("id_year", "1")
.text("label", transaction.label)
.text("date", transaction.inception_time.format("%d/%m/%Y").to_string())
.text("type", Into::<String>::into(transaction.kind))
.text("amount", format!("{}", transaction.amount))
.text("debit", transaction.debit_account_code)
.text("credit", transaction.credit_account_code)
.text("reference", transaction.reference)
;
for linked_id in transaction.linked_users {
form = form.text("linked_users[]", format!("{}", linked_id.0));
}
for linked_id in transaction.linked_services {
form = form.text("linked_services[]", format!("{}", linked_id.0));
}
let res = self.client
.post(self.base_url.join("accounting/transaction")?)
.multipart(form)
.send().await?;
if res.status() != 200 {
return Err(APIClientError::InvalidStatusCode.into());
}
Ok(())
}
}