use std::sync::Arc;
use async_trait::async_trait;
use backbone_messaging::{EventError, IntegrationEventEnvelope, IntegrationEventHandler};
use backbone_orm::org_scope::{self, OrgScope};
use backbone_outbox::inbox;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use sqlx::PgPool;
use uuid::Uuid;
const CONSUMER: &str = "recruitment.hired.compensation";
pub trait HiredEmployeeResolver: Send + Sync {
fn hired_employee_id(&self, envelope: &IntegrationEventEnvelope) -> Option<Uuid>;
}
pub struct HireCompensationHandler {
pool: PgPool,
resolver: Arc<dyn HiredEmployeeResolver>,
}
impl HireCompensationHandler {
pub fn new(pool: PgPool, resolver: Arc<dyn HiredEmployeeResolver>) -> Self {
Self { pool, resolver }
}
fn rpool(&self) -> PgPool {
crate::request_pool::current().unwrap_or_else(|| self.pool.clone())
}
}
fn first_day(p: &serde_json::Value) -> Option<NaiveDate> {
let date = |key: &str| {
serde_json::from_value::<Option<NaiveDate>>(p[key].clone())
.ok()
.flatten()
};
date("start_date").or_else(|| date("join_date"))
}
#[async_trait]
impl IntegrationEventHandler for HireCompensationHandler {
async fn handle(&self, envelope: IntegrationEventEnvelope) -> Result<(), EventError> {
let event_id = Uuid::parse_str(&envelope.id)
.map_err(|e| handler_err(format!("bad envelope id '{}': {e}", envelope.id)))?;
let p = &envelope.payload;
let employee_id = self
.resolver
.hired_employee_id(&envelope)
.ok_or_else(|| handler_err("the hire names no employee".into()))?;
let offer_id: Option<Uuid> = serde_json::from_value(p["offer_id"].clone()).ok().flatten();
let salary: Option<Decimal> = serde_json::from_value::<Option<String>>(p["proposed_salary"].clone())
.ok()
.flatten()
.and_then(|s| s.parse::<Decimal>().ok())
.filter(|d| *d > Decimal::ZERO);
let effective = first_day(p)
.ok_or_else(|| handler_err("payload carries neither start_date nor join_date".into()))?;
let company: Option<Uuid> = serde_json::from_value(p["company_id"].clone()).ok().flatten();
let scope = org_scope::current_org_scope()
.or_else(|| company.map(OrgScope::for_company_unit))
.ok_or_else(|| {
handler_err("no ambient org scope and no payload company_id — cannot place the row".into())
})?;
let mut tx = self.rpool().begin().await.map_err(map_db)?;
org_scope::bind_org_scope_on(&mut tx, &scope)
.await
.map_err(|e| handler_err(format!("org scope bind: {e}")))?;
let first_time = inbox::once(&mut *tx, "payroll", CONSUMER, event_id)
.await
.map_err(|e| handler_err(format!("inbox claim: {e}")))?;
if !first_time {
tx.commit().await.map_err(map_db)?;
return Ok(());
}
let Some(amount) = salary else {
tracing::warn!(
target: "payroll.hire_compensation",
employee_id = %employee_id,
offer_id = ?offer_id,
"hire compensation SKIPPED: the offer carries no salary — record the first \
compensation change by hand"
);
tx.commit().await.map_err(map_db)?;
return Ok(());
};
let employee_exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM employee.employees WHERE id = $1)",
)
.bind(employee_id)
.fetch_one(&mut *tx)
.await
.map_err(map_db)?;
if !employee_exists {
return Err(handler_err(format!(
"the hired employee {employee_id} does not exist yet"
)));
}
sqlx::query(
r#"INSERT INTO payroll.compensation_changes
(employee_id, change_type, new_amount, effective_date, reference_id, note, org_unit_id)
VALUES ($1, 'hire'::compensation_change_type, $2, $3, $4, $5, $6)"#,
)
.bind(employee_id)
.bind(amount)
.bind(effective)
.bind(offer_id)
.bind("hire: the offered salary")
.bind(scope.acting_unit_id())
.execute(&mut *tx)
.await
.map_err(map_db)?;
tx.commit().await.map_err(map_db)?;
Ok(())
}
fn event_patterns(&self) -> Vec<&'static str> {
vec!["recruitment.hired"]
}
fn name(&self) -> &'static str {
"HireCompensationHandler"
}
}
fn map_db(e: sqlx::Error) -> EventError {
handler_err(format!("db: {e}"))
}
fn handler_err(message: String) -> EventError {
EventError::handler(CONSUMER, message)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_first_day_prefers_the_proposed_start_date() {
let d = |s: &str| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok();
assert_eq!(
first_day(&json!({ "start_date": "2026-11-02", "join_date": "2026-10-02" })),
d("2026-11-02")
);
assert_eq!(
first_day(&json!({ "start_date": null, "join_date": "2026-10-02" })),
d("2026-10-02")
);
assert_eq!(first_day(&json!({})), None);
}
}