Skip to main content

backbone_payroll/infrastructure/persistence/
payroll_entry_repository.rs

1//! Repository for PayrollEntry 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 PayrollEntry SQL — the run lifecycle and the transition gate that makes
6//! a run post to the GL at most once (4-layer rule: services orchestrate, repositories hold the SQL).
7//!
8//! Thin newtype over `backbone_orm::GenericCrudRepository<PayrollEntry, backbone_orm::SoftDelete>`.
9//! All standard CRUD methods are available via `Deref`.
10
11use anyhow::Result;
12use chrono::{DateTime, Utc};
13use rust_decimal::Decimal;
14use sqlx::{PgPool, Row};
15use uuid::Uuid;
16
17use backbone_orm::org_scope;
18// The scalar read twin lives only in the legacy `company_scope` module. Its connection discipline
19// is what this adapter needs — request-dedicated connection when the composing service bound one,
20// plain pool otherwise. The helper's legacy task-local branch is never taken: this module sets no
21// legacy scope of its own (ADR-0029).
22use backbone_orm::company_scope::fetch_one_scalar_scoped;
23
24use crate::domain::entity::PayrollEntry;
25
26/// Table name for PayrollEntry entities
27pub const TABLE_NAME: &str = "payroll.payroll_entries";
28
29/// Repository for PayrollEntry entities.
30///
31/// All standard CRUD, soft-delete, pagination, and bulk methods are
32/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
33pub struct PayrollEntryRepository(
34    backbone_orm::GenericCrudRepository<PayrollEntry, backbone_orm::SoftDelete>,
35);
36
37impl std::ops::Deref for PayrollEntryRepository {
38    type Target = backbone_orm::GenericCrudRepository<PayrollEntry, backbone_orm::SoftDelete>;
39    fn deref(&self) -> &Self::Target { &self.0 }
40}
41
42impl PayrollEntryRepository {
43    /// Create a new repository instance.
44    pub fn new(pool: PgPool) -> Self {
45        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
46    }
47}
48
49/// The exact row a run open writes. Mirrors the raw column shape, not the `PayrollEntry` entity: the
50/// insert hard-codes `draft` status and seeds all three totals at 0, so none of those are parameters.
51pub struct NewPayrollEntryRow {
52    pub id: Uuid,
53    pub period_year: i32,
54    pub period_month: i32,
55    /// Non-calendar bounds (both or neither; the CHECK constrains order).
56    pub period_start: Option<chrono::NaiveDate>,
57    pub period_end: Option<chrono::NaiveDate>,
58    pub salary_expense_account_id: Uuid,
59    pub salary_payable_account_id: Uuid,
60}
61
62/// A live run's lifecycle state — what the slip path reads before it writes.
63pub struct RunStateRow {
64    pub status: String,
65}
66
67/// The computed-slip orchestrator's run read: state + the period (which statutory parameter set is
68/// effective) + the expense account overtime/THR earning lines book against.
69pub struct RunPeriodRow {
70    pub status: String,
71    pub period_year: i32,
72    pub period_month: i32,
73    /// Non-calendar bounds (both or neither).
74    pub period_start: Option<chrono::NaiveDate>,
75    pub period_end: Option<chrono::NaiveDate>,
76    pub salary_expense_account_id: Option<Uuid>,
77}
78
79/// What the GL post path reads before it builds the journal. The account columns are nullable in the
80/// schema, so they come back as `Option` for the caller to reject; `journal_id`/`accounting_post_id`
81/// are Some only once posted, which is what makes a re-post return the original journal.
82pub struct RunPostingRow {
83    pub status: String,
84    pub salary_expense_account_id: Option<Uuid>,
85    pub salary_payable_account_id: Option<Uuid>,
86    pub total_gross: Decimal,
87    pub total_deductions: Decimal,
88    pub total_net: Decimal,
89    pub journal_id: Option<Uuid>,
90    pub accounting_post_id: Option<Uuid>,
91}
92
93/// Hand-written PayrollEntry SQL. Lives here (not in the write service) per the module's 4-layer rule:
94/// services orchestrate and own the unit of work, repositories hold the SQL.
95impl PayrollEntryRepository {
96    /// Open a payroll run for a period, as a draft with zeroed totals.
97    ///
98    /// A write outside any transaction: takes the pool and runs `execute_scoped` so the composing
99    /// service's tenancy RLS fence applies (ADR-0029). The caller relays the ambient org request
100    /// scope onto its own transaction first, or runs under HTTP where the request-dedicated
101    /// connection already carries it; an undecorated deployment is unfenced by design.
102    ///
103    /// Returns the raw `sqlx::Error` deliberately: the caller inspects it for a unique violation to
104    /// turn a second run on the same (org unit, year, month) into a domain error.
105    pub async fn insert_entry(
106        &self,
107        pool: &PgPool,
108        e: &NewPayrollEntryRow,
109    ) -> Result<(), sqlx::Error> {
110        org_scope::execute_scoped(
111            pool,
112            sqlx::query(
113                r#"INSERT INTO payroll.payroll_entries
114                     (id, period_year, period_month, period_start, period_end, status,
115                      salary_expense_account_id, salary_payable_account_id,
116                      total_gross, total_deductions, total_net)
117                   VALUES ($1,$2,$3,$6,$7,'draft'::payroll_status,$4,$5,0,0,0)"#,
118            )
119            .bind(e.id).bind(e.period_year).bind(e.period_month)
120            .bind(e.salary_expense_account_id).bind(e.salary_payable_account_id)
121            .bind(e.period_start).bind(e.period_end),
122        )
123        .await?;
124        Ok(())
125    }
126
127    /// Read a live run's status — the slip path's lookup. `Ok(None)` = not found in scope.
128    ///
129    /// ID-only: `fetch_optional_row_scoped` rides a connection carrying the caller's org request
130    /// scope (ADR-0029), so another unit's run simply isn't found. The caller relays the same
131    /// ambient scope onto its own write transaction.
132    pub async fn find_state_by_id(
133        &self,
134        pool: &PgPool,
135        run_id: Uuid,
136    ) -> Result<Option<RunStateRow>, sqlx::Error> {
137        let row = org_scope::fetch_optional_row_scoped(
138            pool,
139            sqlx::query(
140                r#"SELECT status::text AS status FROM payroll.payroll_entries
141                   WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
142            )
143            .bind(run_id),
144        )
145        .await?;
146        Ok(row.map(|r| RunStateRow { status: r.get("status") }))
147    }
148
149    /// The run's period + state, read ID-only under the request scope (same pattern as
150    /// [`Self::find_state_by_id`]).
151    pub async fn find_period_by_id(
152        &self,
153        pool: &PgPool,
154        run_id: Uuid,
155    ) -> Result<Option<RunPeriodRow>, sqlx::Error> {
156        let row = org_scope::fetch_optional_row_scoped(
157            pool,
158            sqlx::query(
159                r#"SELECT status::text AS status, period_year, period_month,
160                          period_start, period_end, salary_expense_account_id
161                   FROM payroll.payroll_entries
162                   WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
163            )
164            .bind(run_id),
165        )
166        .await?;
167        Ok(row.map(|r| RunPeriodRow {
168            status: r.get("status"),
169            period_year: r.get("period_year"),
170            period_month: r.get("period_month"),
171            period_start: r.get("period_start"),
172            period_end: r.get("period_end"),
173            salary_expense_account_id: r.get("salary_expense_account_id"),
174        }))
175    }
176
177    /// Record the rolled-up totals and move `draft → processed`. Returns rows affected: 0 = not draft.
178    ///
179    /// ID-only: `execute_scoped` rides the request-dedicated connection's org request scope
180    /// (ADR-0029). A caller driving its own transaction relays the ambient scope onto it first
181    /// (`bind_org_scope_on`); an undecorated deployment is unfenced by design.
182    pub async fn mark_processed(
183        &self,
184        pool: &PgPool,
185        run_id: Uuid,
186        total_gross: Decimal,
187        total_deductions: Decimal,
188        total_net: Decimal,
189    ) -> Result<u64, sqlx::Error> {
190        let done = org_scope::execute_scoped(
191            pool,
192            sqlx::query(
193                r#"UPDATE payroll.payroll_entries
194                   SET status='processed'::payroll_status, total_gross=$2, total_deductions=$3, total_net=$4
195                   WHERE id=$1 AND status='draft'::payroll_status"#,
196            )
197            .bind(run_id).bind(total_gross).bind(total_deductions).bind(total_net),
198        )
199        .await?;
200        Ok(done.rows_affected())
201    }
202
203    /// Read a live run for GL posting. `Ok(None)` = not found in scope.
204    ///
205    /// ID-only: under HTTP the request-dedicated connection carries the org request scope
206    /// (ADR-0029); a caller driving its own transaction relays the ambient scope onto it first.
207    pub async fn find_for_posting(
208        &self,
209        pool: &PgPool,
210        run_id: Uuid,
211    ) -> Result<Option<RunPostingRow>, sqlx::Error> {
212        let row = org_scope::fetch_optional_row_scoped(
213            pool,
214            sqlx::query(
215                r#"SELECT status::text AS status, salary_expense_account_id, salary_payable_account_id,
216                          total_gross, total_deductions, total_net, journal_id, accounting_post_id
217                   FROM payroll.payroll_entries WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
218            )
219            .bind(run_id),
220        )
221        .await?;
222        Ok(row.map(|r| RunPostingRow {
223            status: r.get("status"),
224            salary_expense_account_id: r.get("salary_expense_account_id"),
225            salary_payable_account_id: r.get("salary_payable_account_id"),
226            total_gross: r.get("total_gross"), total_deductions: r.get("total_deductions"),
227            total_net: r.get("total_net"), journal_id: r.get("journal_id"),
228            accounting_post_id: r.get("accounting_post_id"),
229        }))
230    }
231
232    /// Claim the `processed → posted` transition exactly once, recording the GL's echoed journal + post.
233    /// Returns rows affected: 0 = a racing caller already posted it, so this one re-reads that journal.
234    /// This gate is what makes a run post AT MOST once.
235    ///
236    /// A write outside any transaction: it rides the caller's org request scope (ADR-0029), relayed
237    /// onto the caller's transaction or carried by the request-dedicated connection.
238    pub async fn mark_posted(
239        &self,
240        pool: &PgPool,
241        run_id: Uuid,
242        posting_date: DateTime<Utc>,
243        journal_id: Uuid,
244        post_id: Uuid,
245    ) -> Result<u64, sqlx::Error> {
246        let done = org_scope::execute_scoped(
247            pool,
248            sqlx::query(
249                r#"UPDATE payroll.payroll_entries
250                   SET status='posted'::payroll_status, posting_date=$2, journal_id=$3, accounting_post_id=$4
251                   WHERE id=$1 AND status='processed'::payroll_status"#,
252            )
253            .bind(run_id).bind(posting_date).bind(journal_id).bind(post_id),
254        )
255        .await?;
256        Ok(done.rows_affected())
257    }
258
259    /// Re-read the journal the winner recorded, after a lost `mark_posted` race. Rides the caller's
260    /// org request scope, as above.
261    pub async fn fetch_journal_id(
262        &self,
263        pool: &PgPool,
264        run_id: Uuid,
265    ) -> Result<Uuid, sqlx::Error> {
266        fetch_one_scalar_scoped(
267            pool,
268            sqlx::query_scalar("SELECT journal_id FROM payroll.payroll_entries WHERE id=$1")
269                .bind(run_id),
270        )
271        .await
272    }
273}
274
275backbone_core::impl_crud_repository!(PayrollEntryRepository, PayrollEntry, soft_delete);