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