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