backbone_payroll/infrastructure/persistence/salary_component_repository.rs
1//! Repository for SalaryComponent 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 SalaryComponent SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<SalaryComponent, 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 multi-row read twin lives only in the legacy `company_scope` module. Its connection discipline
16// is what this adapter needs — request-dedicated connection when the composing service bound one,
17// plain pool otherwise. The helper's legacy task-local branch is never taken: this module sets no
18// legacy scope of its own (ADR-0029).
19use backbone_orm::company_scope::fetch_all_rows_scoped;
20
21use crate::domain::entity::SalaryComponent;
22
23/// Table name for SalaryComponent entities
24pub const TABLE_NAME: &str = "payroll.salary_components";
25
26/// Repository for SalaryComponent entities.
27///
28/// All standard CRUD, soft-delete, pagination, and bulk methods are
29/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
30pub struct SalaryComponentRepository(
31 backbone_orm::GenericCrudRepository<SalaryComponent, backbone_orm::SoftDelete>,
32);
33
34impl std::ops::Deref for SalaryComponentRepository {
35 type Target = backbone_orm::GenericCrudRepository<SalaryComponent, backbone_orm::SoftDelete>;
36 fn deref(&self) -> &Self::Target { &self.0 }
37}
38
39impl SalaryComponentRepository {
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 structure component writes.
47///
48/// Mirrors the raw column shape rather than the `SalaryComponent` 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 money-rounded value.
51pub struct NewComponentRow<'a> {
52 pub id: Uuid,
53 pub structure_id: Uuid,
54 pub name: &'a str,
55 pub component_type: &'a str,
56 pub amount: Decimal,
57 pub gl_account_id: Uuid,
58}
59
60/// One structure component, as the slip builder reads it: `component_type` comes back as text so the
61/// service can branch earning-vs-deduction without a domain enum in the way.
62pub struct ComponentRow {
63 pub name: String,
64 pub component_type: String,
65 pub amount: Decimal,
66 pub gl_account_id: Uuid,
67}
68
69/// Hand-written SalaryComponent SQL. Lives here (not in the write service) per the module's 4-layer
70/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
71impl SalaryComponentRepository {
72 /// Insert one earning/deduction component of a structure.
73 ///
74 /// Takes the CALLER'S connection so the structure and all its components commit as ONE unit. The
75 /// caller has already relayed the ambient org request scope onto it — don't re-bind here. The
76 /// component's own org scoping rides its parent structure row (ADR-0029); the module carries no
77 /// denormalized tenancy column.
78 pub async fn insert_component(
79 &self,
80 conn: &mut sqlx::PgConnection,
81 c: &NewComponentRow<'_>,
82 ) -> Result<(), sqlx::Error> {
83 sqlx::query(
84 r#"INSERT INTO payroll.salary_components
85 (id, structure_id, name, component_type, amount, gl_account_id)
86 VALUES ($1,$2,$3,$4::component_type,$5,$6)"#,
87 )
88 .bind(c.id).bind(c.structure_id).bind(c.name).bind(c.component_type)
89 .bind(c.amount).bind(c.gl_account_id)
90 .execute(conn)
91 .await?;
92 Ok(())
93 }
94
95 /// List a structure's components — the earnings and fixed deductions a slip is assembled from.
96 ///
97 /// A read outside any transaction: takes the pool and runs `fetch_all_rows_scoped` so the
98 /// composing service's tenancy RLS fence applies (ADR-0029). The caller relays the ambient org
99 /// request scope onto its own transaction first, or runs under HTTP where the
100 /// request-dedicated connection already carries it.
101 pub async fn list_by_structure(
102 &self,
103 pool: &PgPool,
104 structure_id: Uuid,
105 ) -> Result<Vec<ComponentRow>, sqlx::Error> {
106 let rows = fetch_all_rows_scoped(
107 pool,
108 sqlx::query(
109 "SELECT name, component_type::text AS ct, amount, gl_account_id FROM payroll.salary_components WHERE structure_id=$1")
110 .bind(structure_id),
111 )
112 .await?;
113 Ok(rows.iter().map(|r| ComponentRow {
114 name: r.get("name"), component_type: r.get("ct"), amount: r.get("amount"),
115 gl_account_id: r.get("gl_account_id"),
116 }).collect())
117 }
118}
119
120backbone_core::impl_crud_repository!(SalaryComponentRepository, SalaryComponent, soft_delete);