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