Skip to main content

backbone_payroll/application/service/
offboarding_settlement_handler.rs

1//! Consumer for the `offboarding.closed` compound event — settlement side (ADR-005).
2//!
3//! The payroll module owns the APPLY side of the offboarding final settlement: on each
4//! `offboarding.closed` envelope it appends a `compensation_changes` row carrying the settlement
5//! total, **idempotently**. Registered on the integration bus in backbone-hr-app's
6//! `main.rs` alongside the employee `OffboardingClosedHandler`.
7//!
8//! ## Producer-carried pesangon (no payroll→lifecycle edge)
9//!
10//! The settlement calc lives in `backbone-lifecycle`. To keep the dependency graph acyclic, payroll
11//! does NOT recompute it — the producer (`OffboardingWriteService::close`) runs the calc and embeds
12//! the breakdown in the event payload. This handler just reads the carried breakdown and writes
13//! `compensation_changes` with `change_type='offboarding'`, `new_amount=breakdown.total` (the
14//! settlement's net payable: severance items + unused-leave payout, without the last pay, which
15//! the final payroll run pays), and a note naming every item so the row is self-describing.
16//! Idempotent via `inbox::once` on the event id (preserved from the outbox row id through the
17//! relay).
18//!
19//! Two payload generations are read. The itemised one (PP 35/2021) carries `uang_pesangon`,
20//! `upmk`, `uang_pisah`, `unused_leave_payout`, `net_payable` and `legal_basis`, plus `pesangon`,
21//! `upm` (always 0) and `total` for readers of the earlier shape. The earlier one carries only
22//! `pesangon`, `upmk`, `upm`, `unused_leave_payout` and `total`. Either way the row's amount is
23//! `total`.
24//!
25//! ## Legacy tolerance
26//!
27//! If a payload carries no `pesangon_breakdown` (an event emitted by an older producer), this
28//! handler still commits — it writes `new_amount = 0` with a flagged note rather than poison the
29//! queue. The current producer always carries the breakdown, so this is purely defensive.
30//!
31//! Timeoff balance encashment is a separate target and is intentionally NOT wired here.
32//!
33//! This is a user-owned custom file — it is NEVER regenerated.
34
35use 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
44/// The consumer name stamped into the payroll inbox. The ADR-005 idempotency key for this target is
45/// `("offboarding.settlement", event_id)`; the `event_id` arrives as the envelope id (preserved from
46/// the outbox row id through the relay).
47const CONSUMER: &str = "offboarding.settlement";
48
49/// The carried settlement breakdown, deserialized off the event payload. Payroll owns its own
50/// mirror struct (it must NOT import `backbone-lifecycle`'s type — that would create a Cargo edge
51/// and break the acyclic graph); the field names match the producer's payload. The itemised fields
52/// are optional so an event from an earlier producer still reads.
53#[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
69/// The settlement row's amount and note for a payload's breakdown (or its absence).
70///
71/// The amount is the carried `total`. A payload with no readable breakdown (an older producer)
72/// records zero with a note flagged for manual review rather than poison the queue.
73fn 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            // The itemised (PP 35/2021) payload.
78            (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            // The earlier payload.
91            (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
108/// Integration-event handler that appends the real pesangon settlement row on `offboarding.closed`,
109/// idempotently. Holds only the pool.
110pub struct OffboardingSettlementHandler {
111    pool: PgPool,
112}
113
114impl OffboardingSettlementHandler {
115    /// The database this consumer writes on: the relay binds the tenant's
116    /// pool as the request pool for the whole consumer call (ADR-0029 pool
117    /// law); the composed pool is the fallback.
118    fn rpool(&self) -> sqlx::PgPool {
119        crate::request_pool::current().unwrap_or_else(|| self.pool.clone())
120    }
121
122    /// Create a new handler bound to the given pool.
123    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        // The envelope id IS the outbox row's id (the relay preserves it) → the dedup key.
132        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        // The producer carries the full pesangon breakdown. Parse it; if absent (legacy payload),
142        // fall back to a zero-amount flagged row so the queue is never poisoned.
143        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        // Tenancy (ADR-0029): the module is tenant-agnostic — relay the ambient org request scope
149        // onto our own transaction so the INSERT passes the composing service's tenancy RLS fence.
150        // A RELAY delivery carries no ambient scope, and the composing decorator's org-unit fill
151        // reads one — without a scope the settlement INSERT dies on the fill's kind guard. Fall
152        // back to the payload's owning company leg (the close knows whose tenant it is — fail
153        // closed when the event names neither).
154        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        // Claim the event in-tx with the effect: the inbox row + the settlement insert commit together.
165        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            // change_type='offboarding' is the dedicated enum variant for this; reference_id =
173            // offboarding_id is the non-null idempotency link back to the source workflow.
174            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(&note)
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        // Same pattern as OffboardingClosedHandler — the bus dispatches one event to BOTH handlers.
197        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        // The shape the PP 35/2021 producer emits (numbers as JSON numbers or strings).
237        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}