use async_trait::async_trait;
use backbone_messaging::{EventError, IntegrationEventEnvelope, IntegrationEventHandler};
use backbone_outbox::inbox;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
const CONSUMER: &str = "offboarding.settlement";
#[derive(Debug, Clone, Deserialize)]
struct CarriedBreakdown {
pesangon: Decimal,
upmk: Decimal,
#[serde(default)]
upm: Decimal,
unused_leave_payout: Decimal,
total: Decimal,
#[serde(default)]
uang_pesangon: Option<Decimal>,
#[serde(default)]
uang_pisah: Option<Decimal>,
#[serde(default)]
legal_basis: Option<String>,
}
fn settlement_entry(breakdown: Option<&CarriedBreakdown>, reason: Option<&str>) -> (Decimal, String) {
let reason_note = reason.map(|x| format!(" (reason={x})")).unwrap_or_default();
match breakdown {
Some(b) => match (b.uang_pesangon, b.uang_pisah) {
(Some(uang_pesangon), uang_pisah) => (
b.total,
format!(
"final settlement{reason_note}{}: uang_pesangon={} upmk={} uang_pisah={} unused_leave={} total={}",
b.legal_basis.as_deref().map(|l| format!(" [{l}]")).unwrap_or_default(),
uang_pesangon,
b.upmk,
uang_pisah.unwrap_or(Decimal::ZERO),
b.unused_leave_payout,
b.total,
),
),
(None, _) => (
b.total,
format!(
"pesangon settlement{reason_note}: pesangon={} upmk={} upm={} unused_leave={} total={}",
b.pesangon, b.upmk, b.upm, b.unused_leave_payout, b.total,
),
),
},
None => (
Decimal::ZERO,
format!(
"offboarding settlement: payload carried no pesangon_breakdown — manual review required{reason_note}"
),
),
}
}
pub struct OffboardingSettlementHandler {
pool: PgPool,
}
impl OffboardingSettlementHandler {
fn rpool(&self) -> sqlx::PgPool {
crate::request_pool::current().unwrap_or_else(|| self.pool.clone())
}
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl IntegrationEventHandler for OffboardingSettlementHandler {
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: Uuid = json_field(p, "employee_id")?;
let offboarding_id: Option<Uuid> = serde_json::from_value(p["offboarding_id"].clone()).ok();
let last_working_day: Option<NaiveDate> = serde_json::from_value(p["last_working_day"].clone()).ok();
let reason: Option<String> = serde_json::from_value(p["reason"].clone()).ok();
let breakdown: Option<CarriedBreakdown> =
serde_json::from_value(p["pesangon_breakdown"].clone()).ok();
let mut tx = self.rpool().begin().await.map_err(map_db)?;
let payload_company: Option<Uuid> = serde_json::from_value(p["company_id"].clone()).ok();
let scope = backbone_orm::org_scope::current_org_scope().or_else(|| {
payload_company.map(backbone_orm::org_scope::OrgScope::for_company_unit)
});
if let Some(scope) = scope {
backbone_orm::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 {
let (amount, note) = settlement_entry(breakdown.as_ref(), reason.as_deref());
sqlx::query(
r#"INSERT INTO payroll.compensation_changes (employee_id, change_type, new_amount, effective_date,
reference_id, note,
org_unit_id)
VALUES ($1, 'offboarding'::compensation_change_type, $2, $3, $4, $5, $6::uuid)"#,
)
.bind(employee_id)
.bind(amount)
.bind(last_working_day)
.bind(offboarding_id)
.bind(¬e)
.bind(backbone_orm::org_scope::current_org_scope().map(|s| s.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!["offboarding.closed"]
}
fn name(&self) -> &'static str {
"OffboardingSettlementHandler"
}
}
fn json_field<T>(p: &serde_json::Value, field: &str) -> Result<T, EventError>
where
T: serde::de::DeserializeOwned,
{
serde_json::from_value(p[field].clone())
.map_err(|e| handler_err(format!("payload.{field}: {e}")))
}
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 std::str::FromStr;
fn d(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn parse(payload: serde_json::Value) -> Option<CarriedBreakdown> {
serde_json::from_value(payload["pesangon_breakdown"].clone()).ok()
}
#[test]
fn the_itemised_payload_records_its_total_and_names_the_items() {
let p = serde_json::json!({ "pesangon_breakdown": {
"severance_case": "resignation", "legal_basis": "PP 35/2021 Pasal 50",
"uang_pesangon": "0", "upmk": "0", "uang_pisah": "8000000",
"severance_total": "8000000", "unused_leave_days": "12",
"unused_leave_payout": "4571428.56", "last_pay": "2666666.68",
"last_pay_via_payroll": true, "net_payable": "12571428.56",
"pesangon": "0", "upm": "0", "total": "12571428.56"
}});
let b = parse(p).expect("the itemised breakdown reads");
let (amount, note) = settlement_entry(Some(&b), Some("resignation"));
assert_eq!(amount, d("12571428.56"));
assert!(note.contains("uang_pisah=8000000"), "{note}");
assert!(note.contains("[PP 35/2021 Pasal 50]"), "{note}");
assert!(!note.contains("upm="), "no UPM item under PP 35/2021: {note}");
}
#[test]
fn the_earlier_payload_still_records_its_total() {
let p = serde_json::json!({ "pesangon_breakdown": {
"pesangon": 48000000.0, "upmk": 48000000.0, "upm": 14400000.0,
"unused_leave_payout": 46909090.91, "total": 157309090.91
}});
let b = parse(p).expect("the earlier breakdown reads");
let (amount, note) = settlement_entry(Some(&b), None);
assert_eq!(amount, d("157309090.91"));
assert!(note.starts_with("pesangon settlement: pesangon=48000000"), "{note}");
}
#[test]
fn a_payload_without_a_breakdown_records_zero_for_review() {
let b = parse(serde_json::json!({}));
let (amount, note) = settlement_entry(b.as_ref(), Some("death"));
assert_eq!(amount, Decimal::ZERO);
assert!(note.contains("manual review required (reason=death)"), "{note}");
}
}