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