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