backbone_payroll/infrastructure/persistence/salary_slip_repository.rs
1//! Repository for SalarySlip entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below hold the hand-written SalarySlip SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<SalarySlip, backbone_orm::SoftDelete>`.
8//! All standard CRUD methods are available via `Deref`.
9
10use anyhow::Result;
11use rust_decimal::Decimal;
12use sqlx::{PgPool, Row};
13use uuid::Uuid;
14
15// The one-row read twin lives only in the legacy `company_scope` module. Its connection discipline
16// is what this adapter needs — request-dedicated connection when the composing service bound one,
17// plain pool otherwise. The helper's legacy task-local branch is never taken: this module sets no
18// legacy scope of its own (ADR-0029).
19use backbone_orm::company_scope::fetch_one_row_scoped;
20
21use crate::domain::entity::SalarySlip;
22
23/// Table name for SalarySlip entities
24pub const TABLE_NAME: &str = "payroll.salary_slips";
25
26/// Repository for SalarySlip entities.
27///
28/// All standard CRUD, soft-delete, pagination, and bulk methods are
29/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
30pub struct SalarySlipRepository(
31 backbone_orm::GenericCrudRepository<SalarySlip, backbone_orm::SoftDelete>,
32);
33
34impl std::ops::Deref for SalarySlipRepository {
35 type Target = backbone_orm::GenericCrudRepository<SalarySlip, backbone_orm::SoftDelete>;
36 fn deref(&self) -> &Self::Target { &self.0 }
37}
38
39impl SalarySlipRepository {
40 /// Create a new repository instance.
41 pub fn new(pool: PgPool) -> Self {
42 Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
43 }
44}
45
46/// The exact row an employee's slip writes. Mirrors the raw column shape, not the `SalarySlip` entity:
47/// `unpaid_days` is the service's clamped value and the three money columns are its already-computed,
48/// money-rounded results (`net = gross − deductions`). `overtime_hours`/`tax_method` are the audit
49/// snapshot of how the slip was built — NULL when the caller had no overtime input or computed no
50/// statutory path (both legal; the columns exist so a re-computation can be told apart from history).
51pub struct NewSalarySlipRow {
52 pub id: Uuid,
53 pub payroll_entry_id: Uuid,
54 pub employee_id: Uuid,
55 pub structure_id: Uuid,
56 pub working_days: Decimal,
57 pub unpaid_days: Decimal,
58 pub gross_pay: Decimal,
59 pub total_deductions: Decimal,
60 pub net_pay: Decimal,
61 pub overtime_hours: Option<Decimal>,
62 pub tax_method: Option<String>,
63}
64
65/// A run's slips rolled up. `count` is what distinguishes "an empty run" from "a run summing to zero".
66pub struct SlipTotalsRow {
67 pub total_gross: Decimal,
68 pub total_deductions: Decimal,
69 pub total_net: Decimal,
70 pub count: i64,
71}
72
73/// Hand-written SalarySlip SQL. Lives here (not in the write service) per the module's 4-layer rule:
74/// services orchestrate and own the unit of work, repositories hold the SQL.
75impl SalarySlipRepository {
76 /// Insert an employee's slip into a run.
77 ///
78 /// Takes the CALLER'S connection so the slip and its lines commit as ONE unit. The caller has
79 /// already relayed the ambient org request scope onto it (`bind_org_scope_on`, ADR-0029) so this
80 /// passes the tenancy RLS fence — don't re-bind here.
81 ///
82 /// Returns the raw `sqlx::Error` deliberately: the caller inspects it for a unique violation to turn
83 /// a second slip for the same employee in the same run into a domain error.
84 pub async fn insert_slip(
85 &self,
86 conn: &mut sqlx::PgConnection,
87 s: &NewSalarySlipRow,
88 ) -> Result<(), sqlx::Error> {
89 sqlx::query(
90 r#"INSERT INTO payroll.salary_slips
91 (id, payroll_entry_id, employee_id, structure_id, working_days, unpaid_days,
92 gross_pay, total_deductions, net_pay, overtime_hours, tax_method)
93 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)"#,
94 )
95 .bind(s.id).bind(s.payroll_entry_id).bind(s.employee_id).bind(s.structure_id)
96 .bind(s.working_days).bind(s.unpaid_days).bind(s.gross_pay).bind(s.total_deductions).bind(s.net_pay)
97 .bind(s.overtime_hours).bind(s.tax_method.clone())
98 .execute(conn)
99 .await?;
100 Ok(())
101 }
102
103 /// Roll a run's live slips up into its gross/deduction/net totals, with the slip count.
104 ///
105 /// ID-only: the run id alone identifies the work, so this rides the request-dedicated
106 /// connection's org request scope (ADR-0029). A caller driving its own transaction relays the
107 /// ambient scope onto it first.
108 pub async fn sum_totals_by_run(
109 &self,
110 pool: &PgPool,
111 run_id: Uuid,
112 ) -> Result<SlipTotalsRow, sqlx::Error> {
113 let row = fetch_one_row_scoped(
114 pool,
115 sqlx::query(
116 r#"SELECT COALESCE(SUM(gross_pay),0) AS g, COALESCE(SUM(total_deductions),0) AS d,
117 COALESCE(SUM(net_pay),0) AS n, count(*) AS c
118 FROM payroll.salary_slips WHERE payroll_entry_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
119 )
120 .bind(run_id),
121 )
122 .await?;
123 Ok(SlipTotalsRow {
124 total_gross: row.get("g"), total_deductions: row.get("d"), total_net: row.get("n"),
125 count: row.get("c"),
126 })
127 }
128}
129
130backbone_core::impl_crud_repository!(SalarySlipRepository, SalarySlip, soft_delete);