1use 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
36fn 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 #[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 #[error(transparent)]
70 Statutory(#[from] statutory_calcs::StatutoryError),
71}
72
73impl PayrollError {
74 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 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 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, 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 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
152pub struct StatutoryLine {
162 pub name: String,
163 pub component_type: String, pub amount: Decimal,
165 pub gl_account_id: Uuid, pub source_kind: Option<&'static str>,
168 pub source_ref: Option<Uuid>,
170}
171pub struct NewSalarySlip {
172 pub employee_id: Uuid,
173 pub structure_id: Uuid,
174 pub working_days: Decimal,
176 pub unpaid_days: Decimal,
178 pub statutory: Vec<StatutoryLine>,
179 pub overtime_hours: Decimal,
183 pub timesheet_approval_id: Option<Uuid>,
186 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#[derive(Debug, Clone, PartialEq)]
202pub struct RemitOutcome {
203 pub payroll_entry_id: Uuid,
204 pub remitted: Vec<(RemittanceInstruction, RemitAck)>,
205}
206
207#[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
217pub 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 pub risk_class: i32,
228 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 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 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 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 pub fn with_overtime_inputs(mut self, inputs: Box<dyn OvertimeInputs>) -> Self {
292 self.overtime_inputs = inputs;
293 self
294 }
295
296 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 pub fn with_employee_inputs(mut self, inputs: Box<dyn EmployeeStatutoryInputs>) -> Self {
309 self.employee_inputs = inputs;
310 self
311 }
312
313 pub fn with_gl_sink(mut self, sink: std::sync::Arc<dyn GlPostSink>) -> Self {
316 self.gl_sink = sink;
317 self
318 }
319
320 pub fn with_event_sink(mut self, sink: std::sync::Arc<dyn PayrollEventSink>) -> Self {
323 self.event_sink = sink;
324 self
325 }
326
327 pub fn with_remit_sink(mut self, sink: std::sync::Arc<dyn RemittanceSink>) -> Self {
330 self.remit_sink = sink;
331 self
332 }
333
334 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 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 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 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 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 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 pub async fn add_salary_slip(&self, run_id: Uuid, s: NewSalarySlip) -> Result<Uuid, PayrollError> {
431 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 let unpaid = s.unpaid_days.clamp(Decimal::ZERO, s.working_days);
447 let factor = (s.working_days - unpaid) / s.working_days; 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 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 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 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 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 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 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 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 let cfg = self.params.resolve_as_of("ID", period_start).await?;
593
594 let inputs = self
596 .employee_inputs
597 .statutory_inputs(r.employee_id)
598 .await?
599 .ok_or(PayrollError::NotFound("employee statutory inputs"))?;
600
601 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 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 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 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 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 } 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 pub async fn process_payroll_entry(&self, run_id: Uuid) -> Result<(), PayrollError> {
742 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 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 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 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 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 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 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 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 let ded_rows = self.slip_lines.group_deductions_by_account(&self.rpool(), run_id).await?;
963
964 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 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 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 pub async fn remit_payroll_entry(
1037 &self,
1038 run_id: Uuid,
1039 sink: &dyn RemittanceSink,
1040 ) -> Result<RemitOutcome, PayrollError> {
1041 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}