Skip to main content

backbone_payroll/application/service/
payroll_write_service.rs

1//! The hand-authored payroll write path (user-owned; survives regen).
2//!
3//! A salary run: assemble per-employee slips (earnings from a structure, prorated for HR unpaid days,
4//! minus fixed + supplied statutory deductions), roll up the run totals, and post ONE balanced salary
5//! journal to the GL — the **8th GL producer**: `Dr Salary Expense (gross) · Cr Salary Payable (net) ·
6//! Cr statutory/other payables (grouped by account)`. Because `gross = net + Σ deductions`, it balances.
7//! Idempotent per run (source_id = run id). Reads the HR employee via `period_summary`-style inputs;
8//! the Indonesia statutory amounts (BPJS, PPh 21) are supplied by the deferred overlay. Money is IDR,
9//! 2dp, half-away-from-zero.
10
11use backbone_orm::org_scope;
12use chrono::{Datelike, NaiveDate};
13use rust_decimal::{Decimal, RoundingStrategy};
14use sqlx::PgPool;
15use uuid::Uuid;
16
17use crate::infrastructure::persistence::{
18    NewComponentRow, NewPayrollEntryRow, NewSalarySlipRow, NewSlipLineRow, NewStructureRow,
19    PayrollEntryRepository, SalaryComponentRepository, SalarySlipLineRepository, SalarySlipRepository,
20    SalaryStructureRepository, StatutoryParamsRepository,
21};
22
23use super::employee_inputs_port::{EmployeeStatutoryInputs, PoolEmployeeStatutoryInputs};
24use super::overtime_port::{OvertimeInputs, PoolOvertimeInputs};
25use super::payroll_events::*;
26use super::payroll_gl::*;
27use super::payroll_remittance::{
28    RemitAck, RemittanceInstruction, RemittanceSeamError, RemittanceSink, UnwiredRemittance,
29};
30use super::statutory_calcs::{self, Pph21Method, PtkpTier};
31
32fn money(v: Decimal) -> Decimal {
33    v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
34}
35
36/// The legacy tenancy twin echo (ADR-0029): outbound contract shapes (the GL post envelope, the
37/// `PayrollPosted` event, the remittance instruction) carry a `company_id` field for unstripped
38/// consumers, but the stripped tables hold no company column. Echo the ambient org scope's legacy
39/// company id when the composing service bound one; nil otherwise. Nothing keys a statement on it,
40/// and an undecorated deployment is unfenced by design.
41fn legacy_company_echo() -> Uuid {
42    org_scope::current_org_scope()
43        .and_then(|s| s.legacy_company_id())
44        .unwrap_or(Uuid::nil())
45}
46
47#[derive(Debug, thiserror::Error)]
48pub enum PayrollError {
49    #[error("db: {0}")]
50    Db(#[from] sqlx::Error),
51    #[error("not found: {0}")]
52    NotFound(&'static str),
53    #[error("invalid state: {0}")]
54    InvalidState(&'static str),
55    #[error("invalid input: {0}")]
56    Invalid(String),
57    #[error("unbalanced posting")]
58    Unbalanced,
59    #[error("gl rejected: {0}")]
60    GlRejected(String),
61    /// The post/remit row landed but the event sink refused the event — re-run the post verb; the
62    /// already-posted branch re-publishes (at-least-once), and consumers dedup by record id.
63    #[error("event publish failed after the post landed — re-run the post verb to re-publish: {0}")]
64    EventPublish(String),
65    #[error(transparent)]
66    Remittance(#[from] RemittanceSeamError),
67    /// The statutory parameter resolution refused to compute (no effective rows for the period,
68    /// an incomplete component set, …). Fail-closed by design: never a silent zero tax/pay.
69    #[error(transparent)]
70    Statutory(#[from] statutory_calcs::StatutoryError),
71}
72
73impl PayrollError {
74    /// Stable machine code the HTTP layer surfaces.
75    pub fn code(&self) -> &'static str {
76        match self {
77            Self::Db(_) => "internal_error",
78            Self::NotFound(_) => "not_found",
79            Self::InvalidState(_) => "invalid_state",
80            Self::Invalid(_) => "invalid_input",
81            Self::Unbalanced => "unbalanced",
82            Self::GlRejected(code) => match code.as_str() {
83                "gl_seam_unwired" => "gl_seam_unwired",
84                _ => "gl_rejected",
85            },
86            Self::EventPublish(_) => "event_publish_failed",
87            Self::Remittance(seam) => match seam.code() {
88                "remittance_seam_unwired" => "remittance_seam_unwired",
89                "remittance_rejected" => "remittance_rejected",
90                _ => "remittance_seam_error",
91            },
92            Self::Statutory(e) => match e {
93                statutory_calcs::StatutoryError::NoParamsForPeriod(..) => {
94                    "no_statutory_params_for_period"
95                }
96                statutory_calcs::StatutoryError::UnknownPtkpTier(_) => "unknown_ptkp_tier",
97                statutory_calcs::StatutoryError::UnknownRiskClass(_) => "unknown_risk_class",
98                statutory_calcs::StatutoryError::UnknownTerCategory(_) => "unknown_ter_category",
99                statutory_calcs::StatutoryError::NoTerRates(_) => "no_ter_rates",
100                statutory_calcs::StatutoryError::MissingOvertimeBands => "no_overtime_bands",
101                _ => "statutory_calc_error",
102            },
103        }
104    }
105
106    /// The HTTP status the guarded surface maps this error to. Client-shaped failures (bad input,
107    /// wrong state, unwired seams the operator must compose, missing effective parameters) are
108    /// 4xx/422 so the caller can distinguish them from infrastructure faults.
109    pub fn http_status(&self) -> u16 {
110        match self {
111            Self::Db(_) | Self::EventPublish(_) => 500,
112            Self::NotFound(_) => 404,
113            Self::InvalidState(_) | Self::Invalid(_) | Self::Unbalanced => 422,
114            Self::GlRejected(_) | Self::Remittance(_) => 422,
115            Self::Statutory(e) => match e {
116                // Data-presence failures (an incomplete or non-covering effective set, an axis
117                // value the set has no row for) are client-shaped: the operator seeds the missing
118                // effective set; only parse/IO/db faults are infrastructure.
119                statutory_calcs::StatutoryError::NoParamsForPeriod(..) => 422,
120                statutory_calcs::StatutoryError::UnknownPtkpTier(_) => 422,
121                statutory_calcs::StatutoryError::UnknownRiskClass(_) => 422,
122                statutory_calcs::StatutoryError::UnknownTerCategory(_) => 422,
123                statutory_calcs::StatutoryError::NoTerRates(_) => 422,
124                statutory_calcs::StatutoryError::MissingOvertimeBands => 422,
125                _ => 500,
126            },
127        }
128    }
129}
130
131pub struct NewComponent {
132    pub name: String,
133    pub component_type: String, // earning | deduction
134    pub amount: Decimal,
135    pub gl_account_id: Uuid,
136}
137pub struct NewStructure {
138    pub name: String,
139    pub components: Vec<NewComponent>,
140}
141
142pub struct NewPayrollEntry {
143    pub period_year: i32,
144    pub period_month: i32,
145    /// Non-calendar cut-off (e.g. 26th→25th): both bounds or neither.
146    pub period_start: Option<chrono::NaiveDate>,
147    pub period_end: Option<chrono::NaiveDate>,
148    pub salary_expense_account_id: Uuid,
149    pub salary_payable_account_id: Uuid,
150}
151
152/// A supplied Indonesia statutory component for a slip — PPh 21 / BPJS Kesehatan / BPJS
153/// Ketenagakerjaan **deductions**, or a THR **earning**. Computed by the deferred statutory overlay
154/// and supplied here like billing's tax lines.
155///
156/// `component_type` mirrors the structure-component vocabulary (`"earning"` | `"deduction"`): an
157/// earning raises gross (un-prorated — THR carries its own tenure pro-rating), a deduction subtracts.
158/// The slip-line marks either as `is_statutory: true` so the GL grouping can tell statutory payables
159/// apart from structure deductions; the deduction grouping filters `component_type='deduction'`, so a
160/// THR earning is never mis-routed to a payable account.
161pub struct StatutoryLine {
162    pub name: String,
163    pub component_type: String, // "earning" | "deduction"
164    pub amount: Decimal,
165    pub gl_account_id: Uuid, // payable (deduction) or expense (earning) account
166    /// Provenance: what produced the line (NULL = computed from the structure).
167    pub source_kind: Option<&'static str>,
168    /// The producing record's id (e.g. the timesheet approval row).
169    pub source_ref: Option<Uuid>,
170}
171pub struct NewSalarySlip {
172    pub employee_id: Uuid,
173    pub structure_id: Uuid,
174    /// Working days in the period (e.g. 22); earnings are prorated by (working − unpaid)/working.
175    pub working_days: Decimal,
176    /// Unpaid-leave + uncovered-absence days from `hr.period_summary` — reduce gross.
177    pub unpaid_days: Decimal,
178    pub statutory: Vec<StatutoryLine>,
179    /// Overtime hours consumed while building this slip (0 when none) — stamped on the row as the
180    /// audit snapshot. The PAY for these hours is an ordinary earning line the caller supplies in
181    /// `statutory`/structure lines; the number here only records what the calculation used.
182    pub overtime_hours: Decimal,
183    /// The approved timesheet period this slip consumed (provenance; stamped
184    /// on the run row).
185    pub timesheet_approval_id: Option<Uuid>,
186    /// The PPh-21 path dispatched for this slip (`npwp_brackets` | `ter_a` | `ter_b` | `ter_c`),
187    /// stamped on the row for audit. None when no statutory tax path was computed.
188    pub tax_method: Option<String>,
189}
190
191#[derive(Debug, Clone, PartialEq)]
192pub struct PostOutcome {
193    pub payroll_entry_id: Uuid,
194    pub journal_id: Uuid,
195    pub post_id: Uuid,
196    pub total_net: Decimal,
197    pub already: bool,
198}
199
200/// What the remit verb sent — one (instruction, ack) pair per deduction payable, in send order.
201#[derive(Debug, Clone, PartialEq)]
202pub struct RemitOutcome {
203    pub payroll_entry_id: Uuid,
204    pub remitted: Vec<(RemittanceInstruction, RemitAck)>,
205}
206
207/// The GL payable accounts the computed-slip orchestrator books statutory deductions against —
208/// supplied by the caller until the accounting composition resolves them itself (same stopgap
209/// posture as a caller-supplied posting account set).
210#[derive(Debug, Clone, Copy)]
211pub struct StatutoryAccounts {
212    pub pph21_payable: Uuid,
213    pub bpjs_kesehatan_payable: Uuid,
214    pub bpjs_ketenagakerjaan_payable: Uuid,
215}
216
217/// One computed slip: the orchestrator reads the run + the employee's statutory facts + the
218/// effective parameter set, computes the statutory components and overtime pay, and delegates
219/// the row writes to [`PayrollWriteService::add_salary_slip`].
220pub struct ComputedSlipRequest {
221    pub run_id: Uuid,
222    pub employee_id: Uuid,
223    pub structure_id: Uuid,
224    pub working_days: Decimal,
225    pub unpaid_days: Decimal,
226    /// BPJS JKK risk class 1..=5 — caller-supplied until the HR master carries the field.
227    pub risk_class: i32,
228    /// Payable accounts for the statutory deductions (see [`StatutoryAccounts`]).
229    pub accounts: StatutoryAccounts,
230}
231
232pub struct PayrollWriteService {
233    pool: PgPool,
234    structures: SalaryStructureRepository,
235    components: SalaryComponentRepository,
236    entries: PayrollEntryRepository,
237    slips: SalarySlipRepository,
238    slip_lines: SalarySlipLineRepository,
239    params: StatutoryParamsRepository,
240    overtime_inputs: Box<dyn OvertimeInputs>,
241    timesheet_inputs: std::sync::RwLock<std::sync::Arc<dyn super::timesheet_port::ApprovedTimesheetInputs>>,
242    employee_inputs: Box<dyn EmployeeStatutoryInputs>,
243    gl_sink: std::sync::Arc<dyn GlPostSink>,
244    event_sink: std::sync::Arc<dyn PayrollEventSink>,
245    remit_sink: std::sync::Arc<dyn RemittanceSink>,
246}
247
248impl PayrollWriteService {
249    /// The database this verb runs on: the composer's request pool when the
250    /// tenant router installed one, else the composed pool (ADR-0029 pool law).
251    /// The repositories are rebuilt per call so every read follows.
252    fn rpool(&self) -> sqlx::PgPool {
253        crate::request_pool::current().unwrap_or_else(|| self.pool.clone())
254    }
255
256    pub fn new(pool: PgPool) -> Self {
257        let structures = SalaryStructureRepository::new(pool.clone());
258        let components = SalaryComponentRepository::new(pool.clone());
259        let entries = PayrollEntryRepository::new(pool.clone());
260        let slips = SalarySlipRepository::new(pool.clone());
261        let slip_lines = SalarySlipLineRepository::new(pool.clone());
262        let params = StatutoryParamsRepository::new(pool.clone());
263        // Pool defaults so payroll computes standalone; a host composing the attendance or
264        // employee modules overrides with an adapter over their exports (one SQL owner each).
265        let overtime_inputs: Box<dyn OvertimeInputs> = Box::new(PoolOvertimeInputs::new(pool.clone()));
266        let timesheet_inputs: std::sync::Arc<dyn super::timesheet_port::ApprovedTimesheetInputs> =
267            std::sync::Arc::new(super::timesheet_port::PoolApprovedTimesheet::new(pool.clone()));
268        let employee_inputs: Box<dyn EmployeeStatutoryInputs> =
269            Box::new(PoolEmployeeStatutoryInputs::new(pool.clone()));
270        Self {
271            pool,
272            structures,
273            components,
274            entries,
275            slips,
276            slip_lines,
277            params,
278            overtime_inputs,
279            timesheet_inputs: std::sync::RwLock::new(timesheet_inputs),
280            employee_inputs,
281            // Module-held seams, fail-closed by default: an unwired deployment's post/remit verbs
282            // refuse with the stable seam codes instead of pretending the effect happened.
283            gl_sink: std::sync::Arc::new(UnwiredGlSink),
284            event_sink: std::sync::Arc::new(LoggingSink),
285            remit_sink: std::sync::Arc::new(UnwiredRemittance),
286        }
287    }
288
289    /// Override where overtime hours come from (default: the pool read mirroring attendance's
290    /// export).
291    pub fn with_overtime_inputs(mut self, inputs: Box<dyn OvertimeInputs>) -> Self {
292        self.overtime_inputs = inputs;
293        self
294    }
295
296    /// Wire the approved-timesheet input port (the composing app's adapter
297    /// when it wants the SQL in one place; the pool default reads the
298    /// timesheet tables directly under the fence).
299    pub fn set_timesheet_inputs(
300        &self,
301        inputs: std::sync::Arc<dyn super::timesheet_port::ApprovedTimesheetInputs>,
302    ) {
303        *self.timesheet_inputs.write().expect("timesheet inputs lock poisoned") = inputs;
304    }
305
306    /// Override where employee statutory facts come from (default: the pool read mirroring the
307    /// employee module's export).
308    pub fn with_employee_inputs(mut self, inputs: Box<dyn EmployeeStatutoryInputs>) -> Self {
309        self.employee_inputs = inputs;
310        self
311    }
312
313    /// Override the GL-posting seam (default [`UnwiredGlSink`] — post refuses with
314    /// `gl_seam_unwired`).
315    pub fn with_gl_sink(mut self, sink: std::sync::Arc<dyn GlPostSink>) -> Self {
316        self.gl_sink = sink;
317        self
318    }
319
320    /// Override the domain-event sink (default [`LoggingSink`]). A durable composition stages into
321    /// an outbox here.
322    pub fn with_event_sink(mut self, sink: std::sync::Arc<dyn PayrollEventSink>) -> Self {
323        self.event_sink = sink;
324        self
325    }
326
327    /// Override the remittance seam (default [`UnwiredRemittance`] — remit refuses with
328    /// `remittance_seam_unwired`).
329    pub fn with_remit_sink(mut self, sink: std::sync::Arc<dyn RemittanceSink>) -> Self {
330        self.remit_sink = sink;
331        self
332    }
333
334    /// Post through the module-held GL + event seams — the composition-root convenience over
335    /// [`Self::post_payroll_entry`] (which stays public for callers supplying their own sinks,
336    /// e.g. tests driving a real accounting adapter).
337    pub async fn post_run(&self, run_id: Uuid, posting_date: NaiveDate) -> Result<PostOutcome, PayrollError> {
338        self.post_payroll_entry(run_id, posting_date, &*self.gl_sink, &*self.event_sink).await
339    }
340
341    /// Remit through the module-held remittance seam — the composition-root convenience over
342    /// [`Self::remit_payroll_entry`].
343    pub async fn remit_run(&self, run_id: Uuid) -> Result<RemitOutcome, PayrollError> {
344        self.remit_payroll_entry(run_id, &*self.remit_sink).await
345    }
346
347    /// Define a salary structure with its earning/deduction components.
348    pub async fn create_structure(&self, s: NewStructure) -> Result<Uuid, PayrollError> {
349        if s.name.trim().is_empty() {
350            return Err(PayrollError::Invalid("structure needs a name".into()));
351        }
352        if s.components.is_empty() {
353            return Err(PayrollError::Invalid("a structure needs at least one component".into()));
354        }
355        let id = Uuid::new_v4();
356        // Tenancy (ADR-0029): the module is tenant-agnostic. Relay the ambient org request scope
357        // onto our own transaction so the structure + component inserts pass the composing
358        // service's tenancy RLS fence; an undecorated deployment is unfenced by design.
359        let mut tx = self.rpool().begin().await?;
360        if let Some(scope) = org_scope::current_org_scope() {
361            org_scope::bind_org_scope_on(&mut tx, &scope).await?;
362        }
363        self.structures.insert_structure(&mut tx, &NewStructureRow {
364            id,
365            name: &s.name,
366        }).await?;
367        for c in &s.components {
368            if c.amount < Decimal::ZERO {
369                return Err(PayrollError::Invalid("component amount must be non-negative".into()));
370            }
371            self.components.insert_component(&mut tx, &NewComponentRow {
372                id: Uuid::new_v4(),
373                structure_id: id,
374                name: &c.name,
375                component_type: &c.component_type,
376                amount: money(c.amount),
377                gl_account_id: c.gl_account_id,
378            }).await?;
379        }
380        tx.commit().await?;
381        Ok(id)
382    }
383
384    /// Open a payroll run for a period (draft). Unique per (org unit, year, month) once the
385    /// composing service's tenancy decorator has re-declared the run unique org-scoped.
386    pub async fn create_payroll_entry(&self, e: NewPayrollEntry) -> Result<Uuid, PayrollError> {
387        if !(1..=12).contains(&e.period_month) {
388            return Err(PayrollError::Invalid("period_month must be 1..12".into()));
389        }
390        match (e.period_start, e.period_end) {
391            (Some(start), Some(end)) if start > end => {
392                return Err(PayrollError::Invalid(
393                    "period_start must not be after period_end".into(),
394                ));
395            }
396            (Some(_), None) | (None, Some(_)) => {
397                return Err(PayrollError::Invalid(
398                    "a non-calendar period needs BOTH period_start and period_end".into(),
399                ));
400            }
401            _ => {}
402        }
403        let id = Uuid::new_v4();
404        // Tenancy (ADR-0029): the insert rides the ambient org request scope — under HTTP the
405        // request-dedicated connection already carries it; an undecorated deployment is unfenced
406        // by design.
407        let r = self
408            .entries
409            .insert_entry(&self.rpool(), &NewPayrollEntryRow {
410                id,
411                period_year: e.period_year,
412                period_month: e.period_month,
413                period_start: e.period_start,
414                period_end: e.period_end,
415                salary_expense_account_id: e.salary_expense_account_id,
416                salary_payable_account_id: e.salary_payable_account_id,
417            })
418            .await;
419        match r {
420            Ok(_) => Ok(id),
421            Err(err) if err.as_database_error().map(|d| d.is_unique_violation()).unwrap_or(false) =>
422                Err(PayrollError::Invalid("a payroll run already exists for this period".into())),
423            Err(err) => Err(err.into()),
424        }
425    }
426
427    /// Add an employee's slip to a DRAFT run. Earnings come from the structure, prorated by unpaid days
428    /// (`gross = Σ earning · (working − unpaid)/working`); fixed + supplied statutory deductions subtract.
429    /// `net = gross − deductions` and must be non-negative.
430    pub async fn add_salary_slip(&self, run_id: Uuid, s: NewSalarySlip) -> Result<Uuid, PayrollError> {
431        // Tenancy (ADR-0029), ID-only pattern: identified by the run id alone. The lookup rides the
432        // ambient org request scope — under HTTP the request-dedicated connection carries it, so
433        // another unit's run simply isn't found; an undecorated deployment is unfenced by design.
434        let run = self.entries.find_state_by_id(&self.rpool(), run_id).await?
435            .ok_or(PayrollError::NotFound("payroll run"))?;
436        if run.status != "draft" {
437            return Err(PayrollError::InvalidState("run is not draft"));
438        }
439        if s.working_days <= Decimal::ZERO {
440            return Err(PayrollError::Invalid("working_days must be positive".into()));
441        }
442        // Clamp unpaid days to [0, working_days] so the proration factor stays in [0, 1]. Without the
443        // LOWER clamp a negative unpaid_days (a bad upstream hr.period_summary value) drives factor > 1
444        // and inflates gross ABOVE the structure — a balanced-but-over-booked salary journal (maturity
445        // council 2026-07-08). The DB CHECKs in 20260708000100_payroll_balance_guards backstop any writer.
446        let unpaid = s.unpaid_days.clamp(Decimal::ZERO, s.working_days);
447        let factor = (s.working_days - unpaid) / s.working_days; // proration for unpaid days
448
449        // Load the structure components.
450        let comps = self.components.list_by_structure(&self.rpool(), s.structure_id).await?;
451        if comps.is_empty() {
452            return Err(PayrollError::Invalid("salary structure has no components".into()));
453        }
454
455        struct Line { name: String, ct: String, is_statutory: bool, amount: Decimal, account: Uuid,
456                      source_kind: Option<&'static str>, source_ref: Option<Uuid> }
457        let mut lines: Vec<Line> = Vec::new();
458        let (mut gross, mut deductions) = (Decimal::ZERO, Decimal::ZERO);
459        for c in &comps {
460            let ct = c.component_type.clone();
461            let base = c.amount;
462            let account = c.gl_account_id;
463            if ct == "earning" {
464                let amt = money(base * factor);
465                gross += amt;
466                lines.push(Line { name: c.name.clone(), ct, is_statutory: false, amount: amt, account,
467                                  source_kind: None, source_ref: None });
468            } else {
469                deductions += base;
470                lines.push(Line { name: c.name.clone(), ct, is_statutory: false, amount: base, account,
471                                  source_kind: None, source_ref: None });
472            }
473        }
474        for st in &s.statutory {
475            if st.amount < Decimal::ZERO {
476                return Err(PayrollError::Invalid("statutory amount must be non-negative".into()));
477            }
478            let amt = money(st.amount);
479            // Route by component_type: a THR earning raises gross (un-prorated — THR already carries
480            // its own tenure pro-rating); a deduction subtracts. The slip-line keeps the caller's
481            // component_type so the GL deduction grouping (`component_type='deduction'`) excludes THR.
482            let is_earning = st.component_type == "earning";
483            if is_earning {
484                gross += amt;
485            } else {
486                deductions += amt;
487            }
488            lines.push(Line {
489                name: st.name.clone(),
490                ct: st.component_type.clone(),
491                is_statutory: true,
492                amount: amt,
493                account: st.gl_account_id,
494                source_kind: st.source_kind,
495                source_ref: st.source_ref,
496            });
497        }
498        let net = gross - deductions;
499        if net < Decimal::ZERO {
500            return Err(PayrollError::Invalid("deductions exceed gross — net pay would be negative".into()));
501        }
502
503        let slip_id = Uuid::new_v4();
504        let mut tx = self.rpool().begin().await?;
505        // Relay the ambient org request scope onto our own transaction so the slip + line inserts
506        // pass the composing service's tenancy RLS fence (ADR-0029); an undecorated deployment is
507        // unfenced by design.
508        if let Some(scope) = org_scope::current_org_scope() {
509            org_scope::bind_org_scope_on(&mut tx, &scope).await?;
510        }
511        let ins = self.slips.insert_slip(&mut tx, &NewSalarySlipRow {
512            id: slip_id,
513            payroll_entry_id: run_id,
514            employee_id: s.employee_id,
515            structure_id: s.structure_id,
516            working_days: s.working_days,
517            unpaid_days: unpaid,
518            gross_pay: gross,
519            total_deductions: deductions,
520            net_pay: net,
521            overtime_hours: Some(s.overtime_hours.round_dp(2)),
522            tax_method: s.tax_method.clone(),
523        }).await;
524        if let Err(err) = ins {
525            return Err(if err.as_database_error().map(|d| d.is_unique_violation()).unwrap_or(false) {
526                PayrollError::Invalid("this employee already has a slip in this run".into())
527            } else { err.into() });
528        }
529        for l in &lines {
530            self.slip_lines.insert_line(&mut tx, &NewSlipLineRow {
531                id: Uuid::new_v4(),
532                salary_slip_id: slip_id,
533                name: &l.name,
534                component_type: &l.ct,
535                is_statutory: l.is_statutory,
536                amount: l.amount,
537                gl_account_id: l.account,
538                source_kind: l.source_kind,
539                source_ref: l.source_ref,
540            }).await?;
541        }
542        // The run's provenance stamp: which approved timesheet fed it (the
543        // join the handoff was missing). Rides the same transaction.
544        if let Some(approval) = s.timesheet_approval_id {
545            sqlx::query("UPDATE payroll.payroll_entries SET timesheet_approval_id = $2 WHERE id = $1")
546                .bind(run_id)
547                .bind(approval)
548                .execute(&mut *tx)
549                .await?;
550        }
551        tx.commit().await?;
552        Ok(slip_id)
553    }
554
555    /// Build one employee's slip end-to-end: period → effective statutory params → employee facts →
556    /// TER/bracket dispatch → overtime pay → the same [`Self::add_salary_slip`] write path a manual
557    /// caller uses. The statutory base is the structure's un-prorated monthly earning total (the
558    /// salary being paid); overtime pay rides in as an ordinary earning line so gross stays balanced
559    /// through the existing journal.
560    pub async fn add_computed_salary_slip(&self, r: ComputedSlipRequest) -> Result<Uuid, PayrollError> {
561        let risk_class = u8::try_from(r.risk_class)
562            .ok()
563            .filter(|rc| (1..=5).contains(rc))
564            .ok_or_else(|| PayrollError::Invalid("risk_class must be 1..=5".into()))?;
565        // ID-only read under the request scope (same fence posture as add_salary_slip).
566        let run = self.entries.find_period_by_id(&self.rpool(), r.run_id).await?
567            .ok_or(PayrollError::NotFound("payroll run"))?;
568        if run.status != "draft" {
569            return Err(PayrollError::InvalidState("run is not draft"));
570        }
571        let month = u32::try_from(run.period_month)
572            .map_err(|_| PayrollError::Invalid("period_month is not a valid month".into()))?;
573        // The run's window: its own bounds when it carries a cut-off
574        // (26th→25th is common practice), else the calendar month named by
575        // (period_year, period_month).
576        let (period_start, period_end) = match (run.period_start, run.period_end) {
577            (Some(start), Some(end)) => (start, end),
578            _ => {
579                let start = NaiveDate::from_ymd_opt(run.period_year, month, 1)
580                    .ok_or(PayrollError::Invalid("run period is not a real calendar month".into()))?;
581                // Period end = day before the next month's first (year-rollover safe).
582                let (ny, nm) = if month == 12 { (run.period_year + 1, 1) } else { (run.period_year, month + 1) };
583                let end = NaiveDate::from_ymd_opt(ny, nm, 1)
584                    .and_then(|d| d.pred_opt())
585                    .ok_or(PayrollError::Invalid("run period end is not a real calendar date".into()))?;
586                (start, end)
587            }
588        };
589
590        // Fail-closed parameter resolution: the effective set as of the period's first day. A period
591        // before any seed date (or a table an operator emptied) refuses rather than zeroing tax.
592        let cfg = self.params.resolve_as_of("ID", period_start).await?;
593
594        // Employee facts (PTKP/NPWP/TER/tenure anchor) — None means no such live employee in scope.
595        let inputs = self
596            .employee_inputs
597            .statutory_inputs(r.employee_id)
598            .await?
599            .ok_or(PayrollError::NotFound("employee statutory inputs"))?;
600
601        // Statutory base: the structure's monthly earning total (un-prorated — the salary being
602        // paid; proration is a slip-line concern the earnings factor already applies).
603        let comps = self.components.list_by_structure(&self.rpool(), r.structure_id).await?;
604        let gross_monthly: Decimal = comps
605            .iter()
606            .filter(|c| c.component_type == "earning")
607            .map(|c| c.amount)
608            .sum();
609        if gross_monthly <= Decimal::ZERO {
610            return Err(PayrollError::Invalid("salary structure has no earning components".into()));
611        }
612
613        // Overtime stretches over the period, each DAY priced on the same monthly base (statutory
614        // 173 divisor) — the 1.5× first hour resets daily, so the days are priced separately and
615        // summed — landing as an ordinary earning line so it flows through the balanced journal.
616        let stretches = self
617            .overtime_inputs
618            .overtime_stretches(r.employee_id, period_start, period_end)
619            .await?;
620        let overtime_hours: Decimal = stretches.iter().map(|(_, h)| *h).sum();
621        let salary_expense = run.salary_expense_account_id
622            .ok_or(PayrollError::Invalid("run has no salary expense account".into()))?;
623        let mut statutory: Vec<StatutoryLine> = Vec::new();
624        if overtime_hours > Decimal::ZERO {
625            let mut pay = Decimal::ZERO;
626            for (_, day_hours) in &stretches {
627                pay += statutory_calcs::overtime_pay(*day_hours, gross_monthly, &cfg.overtime)?;
628            }
629            statutory.push(StatutoryLine {
630                name: "Lembur/Overtime".into(),
631                component_type: "earning".into(),
632                amount: pay,
633                gl_account_id: salary_expense,
634                source_kind: Some("attendance_overtime"),
635                source_ref: None,
636            });
637        }
638
639        // The APPROVED-timesheet leg: hours the employee claimed and an
640        // approver signed, priced on the same statutory day ladder and
641        // carrying their provenance (line + run both stamp the approval row).
642        // No approved period contributes nothing — the clock leg above stays
643        // the only overtime source when the timesheet never reached approval.
644        let timesheet_inputs = self
645            .timesheet_inputs
646            .read()
647            .expect("timesheet inputs lock poisoned")
648            .clone();
649        let approved = timesheet_inputs
650            .approved_overtime(r.employee_id, period_start, period_end)
651            .await?;
652        let mut timesheet_approval_id = None;
653        if let Some(a) = approved {
654            let ts_hours: Decimal = a.stretches.iter().map(|(_, h)| *h).sum();
655            if ts_hours > Decimal::ZERO {
656                let mut pay = Decimal::ZERO;
657                for (_, day_hours) in &a.stretches {
658                    pay += statutory_calcs::overtime_pay(*day_hours, gross_monthly, &cfg.overtime)?;
659                }
660                statutory.push(StatutoryLine {
661                    name: "Lembur (jam disetujui)".into(),
662                    component_type: "earning".into(),
663                    amount: pay,
664                    gl_account_id: salary_expense,
665                    source_kind: Some("timesheet_approved"),
666                    source_ref: Some(a.approval_id),
667                });
668                timesheet_approval_id = Some(a.approval_id);
669            } else {
670                timesheet_approval_id = Some(a.approval_id);
671            }
672        }
673
674        // Dispatch: the employee's TER category when set, else the progressive-bracket path.
675        let ptkp: PtkpTier = inputs
676            .ptkp
677            .parse()
678            .map_err(|_| PayrollError::Invalid(format!("unknown ptkp tier '{}'", inputs.ptkp)))?;
679        let method = match inputs.ter_category.as_deref() {
680            None => Pph21Method::NpwpBrackets,
681            Some(s) => Pph21Method::Ter(
682                s.parse()
683                    .map_err(|_| PayrollError::Invalid(format!("unknown ter category '{s}'")))?,
684            ),
685        };
686        // THR tenure: whole months from join to the pay period; unknown join date → 0 (no THR).
687        let tenure_months = Decimal::from(
688            inputs
689                .join_date
690                .map(|j| (run.period_year - j.year()) * 12 + (month as i32 - j.month() as i32))
691                .unwrap_or(0),
692        );
693
694        let components = statutory_calcs::compute_statutory(
695            method,
696            ptkp,
697            inputs.has_npwp,
698            gross_monthly,
699            risk_class,
700            tenure_months,
701            &cfg,
702        )?;
703        for c in components {
704            let gl = if c.component_type == "earning" {
705                salary_expense // THR earning — the journal debits salary expense for the whole gross
706            } else {
707                match c.name.as_str() {
708                    "PPh 21" => r.accounts.pph21_payable,
709                    "BPJS Kesehatan" => r.accounts.bpjs_kesehatan_payable,
710                    "BPJS Ketenagakerjaan" => r.accounts.bpjs_ketenagakerjaan_payable,
711                    other => return Err(PayrollError::Invalid(format!("unroutable statutory component '{other}'"))),
712                }
713            };
714            statutory.push(StatutoryLine {
715                name: c.name,
716                component_type: c.component_type,
717                amount: c.amount,
718                gl_account_id: gl,
719                source_kind: None,
720                source_ref: None,
721            });
722        }
723
724        self.add_salary_slip(
725            r.run_id,
726            NewSalarySlip {
727                employee_id: r.employee_id,
728                structure_id: r.structure_id,
729                working_days: r.working_days,
730                unpaid_days: r.unpaid_days,
731                statutory,
732                overtime_hours,
733                tax_method: Some(method.label().to_string()),
734                timesheet_approval_id,
735            },
736        )
737        .await
738    }
739
740    /// Roll the run's slips up into its totals and move `draft → processed` (ready to post).
741    pub async fn process_payroll_entry(&self, run_id: Uuid) -> Result<(), PayrollError> {
742        // Tenancy (ADR-0029), ID-only pattern: the run id alone identifies the work, so the reads
743        // and the transition ride the ambient org request scope — under HTTP the request-dedicated
744        // connection carries it; an undecorated deployment is unfenced by design.
745        let totals = self.slips.sum_totals_by_run(&self.rpool(), run_id).await?;
746        if totals.count == 0 {
747            return Err(PayrollError::Invalid("a run needs at least one salary slip".into()));
748        }
749        let (g, d, n) = (totals.total_gross, totals.total_deductions, totals.total_net);
750        let moved = self.entries.mark_processed(&self.rpool(), run_id, g, d, n).await?;
751        if moved != 1 {
752            return Err(PayrollError::InvalidState("run is not draft"));
753        }
754        Ok(())
755    }
756
757    /// Post the processed run to the GL — the 8th producer. Builds ONE balanced posting
758    /// (`Dr Salary Expense (gross) · Cr Salary Payable (net) · Cr Σ deduction-account`), drives the
759    /// `GlPostSink` (idempotent per run), then transition-gates `processed → posted` with the journal.
760    /// Posts **at most once**. Emits `PayrollPosted`.
761    /// Render one slip as a PDF (#553). Published-run gated like the
762    /// self-service read: a slip on a draft or processed run is an
763    /// internal draft, not a promise to the employee.
764    pub async fn render_slip_pdf(&self, slip_id: Uuid) -> Result<Vec<u8>, PayrollError> {
765        let mut tx = self.rpool().begin().await?;
766        if let Some(scope) = org_scope::current_org_scope() {
767            org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
768        }
769        use sqlx::Row;
770        let slip = sqlx::query(
771            r#"SELECT s.id, s.employee_id, s.working_days, s.unpaid_days,
772                      s.gross_pay, s.total_deductions, s.net_pay,
773                      p.period_year, p.period_month, p.status::text AS run_status,
774                      e.employee_number, e.first_name, e.last_name,
775                      em.position_id
776                 FROM payroll.salary_slips s
777                 JOIN payroll.payroll_entries p ON p.id = s.payroll_entry_id
778                 JOIN employee.employees e   ON e.id = s.employee_id
779            LEFT JOIN employee.employments em ON em.employee_id = e.id AND em.status = 'active'
780                WHERE s.id = $1
781                  AND p.status = 'posted'
782                  AND (s.metadata->>'deleted_at') IS NULL"#,
783        )
784        .bind(slip_id)
785        .fetch_optional(&mut *tx)
786        .await?;
787        let Some(slip) = slip else {
788            tx.rollback().await?;
789            return Err(PayrollError::NotFound("published salary slip"));
790        };
791        let lines = sqlx::query(
792            r#"SELECT name, amount, is_statutory
793                 FROM payroll.salary_slip_lines WHERE salary_slip_id = $1
794                ORDER BY id"#,
795        )
796        .bind(slip_id)
797        .fetch_all(&mut *tx)
798        .await?;
799        let position: Option<String> = match slip.try_get::<Option<Uuid>, _>("position_id") {
800            Ok(Some(pid)) => {
801                sqlx::query_scalar::<_, Option<String>>(
802                    "SELECT name FROM organization.positions WHERE id = $1",
803                )
804                .bind(pid)
805                .fetch_optional(&mut *tx)
806                .await?
807                .flatten()
808            }
809            _ => None,
810        };
811        tx.commit().await?;
812
813        let mut earnings = Vec::new();
814        let mut deductions = Vec::new();
815        for l in &lines {
816            let row = super::payslip_pdf::SlipRow {
817                label: l.try_get::<String, _>("name")?,
818                amount: l.try_get::<rust_decimal::Decimal, _>("amount")?,
819                statutory: l.try_get::<bool, _>("is_statutory")?,
820            };
821            deductions.push(row);
822        }
823        // The lines carry deductions (the slip's net math); earnings show
824        // the gross roll-up when no named lines exist.
825        if deductions.is_empty() {
826            earnings.push(super::payslip_pdf::SlipRow {
827                label: "Salary".to_string(),
828                amount: slip.try_get::<rust_decimal::Decimal, _>("gross_pay")?,
829                statutory: false,
830            });
831        }
832        let first = slip.try_get::<String, _>("first_name")?;
833        let last = slip
834            .try_get::<Option<String>, _>("last_name")?
835            .unwrap_or_default();
836        let input = super::payslip_pdf::PayslipPdfInput {
837            company_name: "Serpa".to_string(),
838            period: format!(
839                "{}-{:02}",
840                slip.try_get::<i32, _>("period_year")?,
841                slip.try_get::<i32, _>("period_month")?
842            ),
843            employee_number: slip.try_get::<String, _>("employee_number")?,
844            employee_name: format!("{} {}", first, last).trim().to_string(),
845            position_title: position,
846            working_days: slip.try_get::<rust_decimal::Decimal, _>("working_days")?,
847            unpaid_days: slip.try_get::<rust_decimal::Decimal, _>("unpaid_days")?,
848            gross_pay: slip.try_get::<rust_decimal::Decimal, _>("gross_pay")?,
849            total_deductions: slip.try_get::<rust_decimal::Decimal, _>("total_deductions")?,
850            net_pay: slip.try_get::<rust_decimal::Decimal, _>("net_pay")?,
851            earnings,
852            deductions,
853        };
854        Ok(super::payslip_pdf::render_payslip_pdf(&input))
855    }
856
857    /// Cancel a run that has not left draft (#605): the month opens for a
858    /// fresh run, the slips go with it. Only `draft` may cancel (posted
859    /// history is immutable — reverse, don't delete); idempotent on an
860    /// already-cancelled row.
861    pub async fn cancel_payroll_entry(
862        &self,
863        run_id: Uuid,
864    ) -> Result<bool, PayrollError> {
865        let mut tx = self.rpool().begin().await?;
866        if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
867            backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope).await?;
868        }
869        let status: Option<String> = sqlx::query_scalar(
870            "SELECT status::text FROM payroll.payroll_entries WHERE id = $1 FOR UPDATE",
871        )
872        .bind(run_id)
873        .fetch_optional(&mut *tx)
874        .await?;
875        match status.as_deref() {
876            None => return Err(PayrollError::NotFound("payroll run")),
877            Some("cancelled") => {
878                tx.rollback().await?;
879                return Ok(false);
880            }
881            Some("draft") => {
882                // Draft: the slips go with the run, the month reopens.
883                sqlx::query("DELETE FROM payroll.salary_slip_lines WHERE salary_slip_id IN (SELECT id FROM payroll.salary_slips WHERE payroll_entry_id = $1)")
884                    .bind(run_id)
885                    .execute(&mut *tx)
886                    .await?;
887                sqlx::query("DELETE FROM payroll.salary_slips WHERE payroll_entry_id = $1")
888                    .bind(run_id)
889                    .execute(&mut *tx)
890                    .await?;
891            }
892            // Processed (computed, reviewed, NOT yet posted): the run
893            // closes without touching the GL — nothing was posted, so
894            // there is nothing to reverse (#615). The slips stay for the
895            // audit trail and the month reopens for a fresh run.
896            Some("processed") => {}
897            Some(other) => {
898                tx.rollback().await?;
899                return Err(PayrollError::Invalid(
900                    format!("run is {other} — only a draft or processed run may be cancelled"),
901                ));
902            }
903        }
904        sqlx::query("UPDATE payroll.payroll_entries SET status = 'cancelled' WHERE id = $1 AND status IN ('draft', 'processed')")
905            .bind(run_id)
906            .execute(&mut *tx)
907            .await?;
908        tx.commit().await?;
909        Ok(true)
910    }
911
912    pub async fn post_payroll_entry(
913        &self,
914        run_id: Uuid,
915        posting_date: chrono::NaiveDate,
916        sink: &dyn GlPostSink,
917        events: &dyn PayrollEventSink,
918    ) -> Result<PostOutcome, PayrollError> {
919        // Tenancy (ADR-0029), ID-only pattern: identified by the run id alone. The reads ride the
920        // ambient org request scope — under HTTP the request-dedicated connection carries it; an
921        // undecorated deployment is unfenced by design.
922        let run = self.entries.find_for_posting(&self.rpool(), run_id).await?
923            .ok_or(PayrollError::NotFound("payroll run"))?;
924        let status = run.status.as_str();
925        let total_net = run.total_net;
926        if status == "posted" {
927            let j: Uuid = run.journal_id.ok_or(PayrollError::InvalidState("posted without a journal"))?;
928            let p: Uuid = run.accounting_post_id.unwrap_or(j);
929            // At-least-once delivery: a retried post re-publishes (the first attempt surfaced a
930            // publish failure as an error even though its row landed). Consumers dedup by record
931            // id, so a re-stage after a partial delivery is absorbed, never duplicated downstream.
932            let payables = self.payables_for_run(run_id).await?;
933            events
934                .publish(&PayrollEvent::PayrollPosted(PayrollPosted {
935                    payroll_entry_id: run_id,
936                    company_id: legacy_company_echo(),
937                    journal_id: j,
938                    post_id: p,
939                    total_gross: run.total_gross,
940                    total_deductions: run.total_deductions,
941                    total_net,
942                    salary_payable_account_id: run.salary_payable_account_id
943                        .ok_or(PayrollError::InvalidState("posted without a salary payable account"))?,
944                    payables,
945                }))
946                .await
947                .map_err(|e| PayrollError::EventPublish(e.to_string()))?;
948            return Ok(PostOutcome { payroll_entry_id: run_id, journal_id: j, post_id: p, total_net, already: true });
949        }
950        if status != "processed" {
951            return Err(PayrollError::InvalidState("run is not processed"));
952        }
953        let total_gross = run.total_gross;
954        let total_deductions = run.total_deductions;
955        let salary_expense: Uuid = run.salary_expense_account_id
956            .ok_or(PayrollError::Invalid("run has no salary expense account".into()))?;
957        let salary_payable: Uuid = run.salary_payable_account_id
958            .ok_or(PayrollError::Invalid("run has no salary payable account".into()))?;
959
960        // Deductions grouped by their payable account across every slip, carrying whether the account is
961        // a statutory payable (routes the settlement consumer's remittance to the right authority).
962        let ded_rows = self.slip_lines.group_deductions_by_account(&self.rpool(), run_id).await?;
963
964        // Build the balanced posting: Dr Expense (gross) · Cr Payable (net) · Cr each deduction account.
965        // The same grouping becomes the payable breakdown on PayrollPosted (settlement's input).
966        let mut lines = vec![
967            GlPostLine::debit(salary_expense, total_gross).with_description("Salary expense"),
968            GlPostLine::credit(salary_payable, total_net).with_description("Net pay payable"),
969        ];
970        let mut payables: Vec<PayrollPayable> = Vec::new();
971        for r in &ded_rows {
972            let acct = r.gl_account_id;
973            let amt = r.amount;
974            if amt > Decimal::ZERO {
975                lines.push(GlPostLine::credit(acct, amt).with_description("Payroll deduction payable"));
976                payables.push(PayrollPayable { gl_account_id: acct, amount: amt, statutory: r.statutory });
977            }
978        }
979        let env = AccountingPostEnvelope {
980            idempotency_key: format!("payroll:{run_id}"),
981            company_id: legacy_company_echo(),
982            branch_id: None, source_type: "payroll".into(), source_id: run_id,
983            source_reference: None, posting_date, currency: "IDR".into(), posting_type: "original".into(),
984            description: Some("Payroll run".into()), lines,
985        };
986        if !env.is_balanced() {
987            return Err(PayrollError::Unbalanced);
988        }
989
990        let ack = sink.post(&env).await.map_err(|r| PayrollError::GlRejected(r.code))?;
991
992        let posted_at = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
993            posting_date
994                .and_hms_opt(0, 0, 0)
995                .ok_or(PayrollError::Invalid("posting date is not a real calendar date".into()))?,
996            chrono::Utc,
997        );
998        let moved = self
999            .entries
1000            .mark_posted(&self.rpool(), run_id, posted_at, ack.journal_id, ack.post_id)
1001            .await?;
1002        if moved != 1 {
1003            // Raced — the winner posted; return its journal.
1004            let j: Uuid = self.entries.fetch_journal_id(&self.rpool(), run_id).await?;
1005            return Ok(PostOutcome { payroll_entry_id: run_id, journal_id: j, post_id: ack.post_id, total_net, already: true });
1006        }
1007        events
1008            .publish(&PayrollEvent::PayrollPosted(PayrollPosted {
1009                payroll_entry_id: run_id, company_id: legacy_company_echo(), journal_id: ack.journal_id, post_id: ack.post_id,
1010                total_gross, total_deductions, total_net,
1011                salary_payable_account_id: salary_payable, payables,
1012            }))
1013            .await
1014            .map_err(|e| PayrollError::EventPublish(e.to_string()))?;
1015        Ok(PostOutcome { payroll_entry_id: run_id, journal_id: ack.journal_id, post_id: ack.post_id, total_net, already: false })
1016    }
1017
1018    /// The run's deduction payables, grouped by account exactly as the post verb grouped them —
1019    /// the shared source for the already-posted re-publish and the remit verb, so both describe
1020    /// the SAME obligations the posted journal credited.
1021    async fn payables_for_run(&self, run_id: Uuid) -> Result<Vec<PayrollPayable>, PayrollError> {
1022        let ded_rows = self.slip_lines.group_deductions_by_account(&self.rpool(), run_id).await?;
1023        Ok(ded_rows
1024            .into_iter()
1025            .filter(|r| r.amount > Decimal::ZERO)
1026            .map(|r| PayrollPayable { gl_account_id: r.gl_account_id, amount: r.amount, statutory: r.statutory })
1027            .collect())
1028    }
1029
1030    /// Remit a posted run's payables — one instruction per deduction account, each carrying the
1031    /// stable `payroll_remittance:{company}:{run}:{account}` idempotency key so retries dedup at
1032    /// the sink (the company segment is the legacy tenancy twin echo, ADR-0029 — stable per unit
1033    /// under a composing service, nil undecorated). Requires `posted` (an unposted run has no
1034    /// settled obligations to pay). Payee resolution is the composing host's adapter, never
1035    /// payroll's.
1036    pub async fn remit_payroll_entry(
1037        &self,
1038        run_id: Uuid,
1039        sink: &dyn RemittanceSink,
1040    ) -> Result<RemitOutcome, PayrollError> {
1041        // Tenancy (ADR-0029), ID-only pattern — see post_payroll_entry.
1042        let run = self.entries.find_for_posting(&self.rpool(), run_id).await?
1043            .ok_or(PayrollError::NotFound("payroll run"))?;
1044        if run.status.as_str() != "posted" {
1045            return Err(PayrollError::InvalidState("run is not posted"));
1046        }
1047        let payables = self.payables_for_run(run_id).await?;
1048        let mut remitted = Vec::with_capacity(payables.len());
1049        for p in payables {
1050            let instruction =
1051                RemittanceInstruction::new(legacy_company_echo(), run_id, p.gl_account_id, p.amount, p.statutory);
1052            let ack: RemitAck = sink.remit(&instruction).await?;
1053            remitted.push((instruction, ack));
1054        }
1055        Ok(RemitOutcome { payroll_entry_id: run_id, remitted })
1056    }
1057}