Skip to main content

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);