backbone_payroll/application/service/
offboarding_settlement_handler.rs1use async_trait::async_trait;
36use backbone_messaging::{EventError, IntegrationEventEnvelope, IntegrationEventHandler};
37use backbone_outbox::inbox;
38use chrono::NaiveDate;
39use rust_decimal::Decimal;
40use serde::Deserialize;
41use sqlx::PgPool;
42use uuid::Uuid;
43
44const CONSUMER: &str = "offboarding.settlement";
48
49#[derive(Debug, Clone, Deserialize)]
54struct CarriedBreakdown {
55 pesangon: Decimal,
56 upmk: Decimal,
57 #[serde(default)]
58 upm: Decimal,
59 unused_leave_payout: Decimal,
60 total: Decimal,
61 #[serde(default)]
62 uang_pesangon: Option<Decimal>,
63 #[serde(default)]
64 uang_pisah: Option<Decimal>,
65 #[serde(default)]
66 legal_basis: Option<String>,
67}
68
69fn settlement_entry(breakdown: Option<&CarriedBreakdown>, reason: Option<&str>) -> (Decimal, String) {
74 let reason_note = reason.map(|x| format!(" (reason={x})")).unwrap_or_default();
75 match breakdown {
76 Some(b) => match (b.uang_pesangon, b.uang_pisah) {
77 (Some(uang_pesangon), uang_pisah) => (
79 b.total,
80 format!(
81 "final settlement{reason_note}{}: uang_pesangon={} upmk={} uang_pisah={} unused_leave={} total={}",
82 b.legal_basis.as_deref().map(|l| format!(" [{l}]")).unwrap_or_default(),
83 uang_pesangon,
84 b.upmk,
85 uang_pisah.unwrap_or(Decimal::ZERO),
86 b.unused_leave_payout,
87 b.total,
88 ),
89 ),
90 (None, _) => (
92 b.total,
93 format!(
94 "pesangon settlement{reason_note}: pesangon={} upmk={} upm={} unused_leave={} total={}",
95 b.pesangon, b.upmk, b.upm, b.unused_leave_payout, b.total,
96 ),
97 ),
98 },
99 None => (
100 Decimal::ZERO,
101 format!(
102 "offboarding settlement: payload carried no pesangon_breakdown — manual review required{reason_note}"
103 ),
104 ),
105 }
106}
107
108pub struct OffboardingSettlementHandler {
111 pool: PgPool,
112}
113
114impl OffboardingSettlementHandler {
115 fn rpool(&self) -> sqlx::PgPool {
119 crate::request_pool::current().unwrap_or_else(|| self.pool.clone())
120 }
121
122 pub fn new(pool: PgPool) -> Self {
124 Self { pool }
125 }
126}
127
128#[async_trait]
129impl IntegrationEventHandler for OffboardingSettlementHandler {
130 async fn handle(&self, envelope: IntegrationEventEnvelope) -> Result<(), EventError> {
131 let event_id = Uuid::parse_str(&envelope.id)
133 .map_err(|e| handler_err(format!("bad envelope id '{}': {e}", envelope.id)))?;
134
135 let p = &envelope.payload;
136 let employee_id: Uuid = json_field(p, "employee_id")?;
137 let offboarding_id: Option<Uuid> = serde_json::from_value(p["offboarding_id"].clone()).ok();
138 let last_working_day: Option<NaiveDate> = serde_json::from_value(p["last_working_day"].clone()).ok();
139 let reason: Option<String> = serde_json::from_value(p["reason"].clone()).ok();
140
141 let breakdown: Option<CarriedBreakdown> =
144 serde_json::from_value(p["pesangon_breakdown"].clone()).ok();
145
146 let mut tx = self.rpool().begin().await.map_err(map_db)?;
147
148 let payload_company: Option<Uuid> = serde_json::from_value(p["company_id"].clone()).ok();
155 let scope = backbone_orm::org_scope::current_org_scope().or_else(|| {
156 payload_company.map(backbone_orm::org_scope::OrgScope::for_company_unit)
157 });
158 if let Some(scope) = scope {
159 backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope)
160 .await
161 .map_err(|e| handler_err(format!("org scope bind: {e}")))?;
162 }
163
164 let first_time = inbox::once(&mut *tx, "payroll", CONSUMER, event_id)
166 .await
167 .map_err(|e| handler_err(format!("inbox claim: {e}")))?;
168
169 if first_time {
170 let (amount, note) = settlement_entry(breakdown.as_ref(), reason.as_deref());
171
172 sqlx::query(
175 r#"INSERT INTO payroll.compensation_changes (employee_id, change_type, new_amount, effective_date,
176 reference_id, note,
177 org_unit_id)
178 VALUES ($1, 'offboarding'::compensation_change_type, $2, $3, $4, $5, $6::uuid)"#,
179 )
180 .bind(employee_id)
181 .bind(amount)
182 .bind(last_working_day)
183 .bind(offboarding_id)
184 .bind(¬e)
185 .bind(backbone_orm::org_scope::current_org_scope().map(|s| s.acting_unit_id()))
186 .execute(&mut *tx)
187 .await
188 .map_err(map_db)?;
189 }
190
191 tx.commit().await.map_err(map_db)?;
192 Ok(())
193 }
194
195 fn event_patterns(&self) -> Vec<&'static str> {
196 vec!["offboarding.closed"]
198 }
199
200 fn name(&self) -> &'static str {
201 "OffboardingSettlementHandler"
202 }
203}
204
205fn json_field<T>(p: &serde_json::Value, field: &str) -> Result<T, EventError>
206where
207 T: serde::de::DeserializeOwned,
208{
209 serde_json::from_value(p[field].clone())
210 .map_err(|e| handler_err(format!("payload.{field}: {e}")))
211}
212
213fn map_db(e: sqlx::Error) -> EventError {
214 handler_err(format!("db: {e}"))
215}
216
217fn handler_err(message: String) -> EventError {
218 EventError::handler(CONSUMER, message)
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use std::str::FromStr;
225
226 fn d(s: &str) -> Decimal {
227 Decimal::from_str(s).unwrap()
228 }
229
230 fn parse(payload: serde_json::Value) -> Option<CarriedBreakdown> {
231 serde_json::from_value(payload["pesangon_breakdown"].clone()).ok()
232 }
233
234 #[test]
235 fn the_itemised_payload_records_its_total_and_names_the_items() {
236 let p = serde_json::json!({ "pesangon_breakdown": {
238 "severance_case": "resignation", "legal_basis": "PP 35/2021 Pasal 50",
239 "uang_pesangon": "0", "upmk": "0", "uang_pisah": "8000000",
240 "severance_total": "8000000", "unused_leave_days": "12",
241 "unused_leave_payout": "4571428.56", "last_pay": "2666666.68",
242 "last_pay_via_payroll": true, "net_payable": "12571428.56",
243 "pesangon": "0", "upm": "0", "total": "12571428.56"
244 }});
245 let b = parse(p).expect("the itemised breakdown reads");
246 let (amount, note) = settlement_entry(Some(&b), Some("resignation"));
247 assert_eq!(amount, d("12571428.56"));
248 assert!(note.contains("uang_pisah=8000000"), "{note}");
249 assert!(note.contains("[PP 35/2021 Pasal 50]"), "{note}");
250 assert!(!note.contains("upm="), "no UPM item under PP 35/2021: {note}");
251 }
252
253 #[test]
254 fn the_earlier_payload_still_records_its_total() {
255 let p = serde_json::json!({ "pesangon_breakdown": {
256 "pesangon": 48000000.0, "upmk": 48000000.0, "upm": 14400000.0,
257 "unused_leave_payout": 46909090.91, "total": 157309090.91
258 }});
259 let b = parse(p).expect("the earlier breakdown reads");
260 let (amount, note) = settlement_entry(Some(&b), None);
261 assert_eq!(amount, d("157309090.91"));
262 assert!(note.starts_with("pesangon settlement: pesangon=48000000"), "{note}");
263 }
264
265 #[test]
266 fn a_payload_without_a_breakdown_records_zero_for_review() {
267 let b = parse(serde_json::json!({}));
268 let (amount, note) = settlement_entry(b.as_ref(), Some("death"));
269 assert_eq!(amount, Decimal::ZERO);
270 assert!(note.contains("manual review required (reason=death)"), "{note}");
271 }
272}