Skip to main content

backbone_payroll/infrastructure/persistence/
salary_slip_line_repository.rs

1//! Repository for SalarySlipLine 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 SalarySlipLine SQL — including the deduction grouping the salary journal
6//! is built from (4-layer rule: services orchestrate, repositories hold the SQL).
7//!
8//! Thin newtype over `backbone_orm::GenericCrudRepository<SalarySlipLine, backbone_orm::SoftDelete>`.
9//! All standard CRUD methods are available via `Deref`.
10
11use anyhow::Result;
12use rust_decimal::Decimal;
13use sqlx::{PgPool, Row};
14use uuid::Uuid;
15
16// The multi-row read twin rides the `org_scope` module: request-dedicated connection when
17// the composer bound one, plain pool otherwise, no scope invented. This module sets no
18// scope of its own (ADR-0029), so nothing reaches for a legacy company bind.
19use backbone_orm::org_scope::fetch_all_rows_scoped;
20
21use crate::domain::entity::SalarySlipLine;
22
23/// Table name for SalarySlipLine entities
24pub const TABLE_NAME: &str = "payroll.salary_slip_lines";
25
26/// Repository for SalarySlipLine entities.
27///
28/// All standard CRUD, soft-delete, pagination, and bulk methods are
29/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
30pub struct SalarySlipLineRepository(
31    backbone_orm::GenericCrudRepository<SalarySlipLine, backbone_orm::SoftDelete>,
32);
33
34impl std::ops::Deref for SalarySlipLineRepository {
35    type Target = backbone_orm::GenericCrudRepository<SalarySlipLine, backbone_orm::SoftDelete>;
36    fn deref(&self) -> &Self::Target { &self.0 }
37}
38
39impl SalarySlipLineRepository {
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 one slip line writes.
47///
48/// Mirrors the raw column shape rather than the `SalarySlipLine` entity: `component_type` is cast at
49/// the DB (`$4::component_type`), which is what lets a bad variant fail as a DB error instead of a
50/// deserialize panic. `amount` is the service's already-prorated, money-rounded value.
51pub struct NewSlipLineRow<'a> {
52    pub id: Uuid,
53    pub salary_slip_id: Uuid,
54    pub name: &'a str,
55    pub component_type: &'a str,
56    pub is_statutory: bool,
57    pub amount: Decimal,
58    pub gl_account_id: Uuid,
59    /// Provenance (NULL = structure-computed).
60    pub source_kind: Option<&'static str>,
61    pub source_ref: Option<Uuid>,
62}
63
64/// One deduction payable account's total across a run's slips.
65///
66/// `statutory` is `bool_or` over the group: it routes the settlement consumer's remittance to the right
67/// authority, so an account carrying ANY statutory line counts as statutory.
68pub struct DeductionGroupRow {
69    pub gl_account_id: Uuid,
70    pub amount: Decimal,
71    pub statutory: bool,
72}
73
74/// Hand-written SalarySlipLine SQL. Lives here (not in the write service) per the module's 4-layer
75/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
76impl SalarySlipLineRepository {
77    /// Insert one line of a slip.
78    ///
79    /// Takes the CALLER'S connection so every line and its slip commit as ONE unit. The caller has
80    /// already relayed the ambient org request scope onto it — don't re-bind here. The line's own org
81    /// scoping rides its parent slip row (ADR-0029); the module carries no denormalized tenancy
82    /// column.
83    pub async fn insert_line(
84        &self,
85        conn: &mut sqlx::PgConnection,
86        l: &NewSlipLineRow<'_>,
87    ) -> Result<(), sqlx::Error> {
88        sqlx::query(
89            r#"INSERT INTO payroll.salary_slip_lines
90                 (id, salary_slip_id, name, component_type, is_statutory, amount, gl_account_id,
91                  source_kind, source_ref)
92               VALUES ($1,$2,$3,$4::component_type,$5,$6,$7,$8,$9)"#,
93        )
94        .bind(l.id).bind(l.salary_slip_id).bind(l.name).bind(l.component_type)
95        .bind(l.is_statutory).bind(l.amount).bind(l.gl_account_id)
96        .bind(l.source_kind).bind(l.source_ref)
97        .execute(conn)
98        .await?;
99        Ok(())
100    }
101
102    /// Group a run's deductions by their payable account across every live slip — the credit side of the
103    /// salary journal, and the same grouping that becomes `PayrollPosted`'s payable breakdown
104    /// (settlement's input).
105    ///
106    /// A read outside any transaction: takes the pool and runs `fetch_all_rows_scoped` so the
107    /// composing service's tenancy RLS fence applies (ADR-0029). The caller relays the ambient org
108    /// request scope onto its own transaction first, or runs under HTTP where the
109    /// request-dedicated connection already carries it.
110    pub async fn group_deductions_by_account(
111        &self,
112        pool: &PgPool,
113        run_id: Uuid,
114    ) -> Result<Vec<DeductionGroupRow>, sqlx::Error> {
115        let rows = fetch_all_rows_scoped(
116            pool,
117            sqlx::query(
118                r#"SELECT l.gl_account_id, SUM(l.amount) AS amt, bool_or(l.is_statutory) AS statutory
119                   FROM payroll.salary_slip_lines l JOIN payroll.salary_slips s ON s.id = l.salary_slip_id
120                   WHERE s.payroll_entry_id=$1 AND l.component_type='deduction'::component_type
121                     AND (s.metadata->>'deleted_at') IS NULL
122                   GROUP BY l.gl_account_id"#,
123            )
124            .bind(run_id),
125        )
126        .await?;
127        Ok(rows.iter().map(|r| DeductionGroupRow {
128            gl_account_id: r.get("gl_account_id"), amount: r.get("amt"), statutory: r.get("statutory"),
129        }).collect())
130    }
131}
132
133backbone_core::impl_crud_repository!(SalarySlipLineRepository, SalarySlipLine, soft_delete);