backbone-payroll 0.3.49

Payroll: salary structures, payroll runs and computed salary slips over effective-dated statutory tables, plus compensation changes
Documentation
//! Repository for SalarySlip entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the hand-written SalarySlip SQL (4-layer rule: services orchestrate, repos hold SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<SalarySlip, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

use anyhow::Result;
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;

// The one-row read twin lives only in the legacy `company_scope` module. Its connection discipline
// is what this adapter needs — request-dedicated connection when the composing service bound one,
// plain pool otherwise. The helper's legacy task-local branch is never taken: this module sets no
// legacy scope of its own (ADR-0029).
use backbone_orm::company_scope::fetch_one_row_scoped;

use crate::domain::entity::SalarySlip;

/// Table name for SalarySlip entities
pub const TABLE_NAME: &str = "payroll.salary_slips";

/// Repository for SalarySlip entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct SalarySlipRepository(
    backbone_orm::GenericCrudRepository<SalarySlip, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for SalarySlipRepository {
    type Target = backbone_orm::GenericCrudRepository<SalarySlip, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target { &self.0 }
}

impl SalarySlipRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// The exact row an employee's slip writes. Mirrors the raw column shape, not the `SalarySlip` entity:
/// `unpaid_days` is the service's clamped value and the three money columns are its already-computed,
/// money-rounded results (`net = gross − deductions`). `overtime_hours`/`tax_method` are the audit
/// snapshot of how the slip was built — NULL when the caller had no overtime input or computed no
/// statutory path (both legal; the columns exist so a re-computation can be told apart from history).
pub struct NewSalarySlipRow {
    pub id: Uuid,
    pub payroll_entry_id: Uuid,
    pub employee_id: Uuid,
    pub structure_id: Uuid,
    pub working_days: Decimal,
    pub unpaid_days: Decimal,
    pub gross_pay: Decimal,
    pub total_deductions: Decimal,
    pub net_pay: Decimal,
    pub overtime_hours: Option<Decimal>,
    pub tax_method: Option<String>,
}

/// A run's slips rolled up. `count` is what distinguishes "an empty run" from "a run summing to zero".
pub struct SlipTotalsRow {
    pub total_gross: Decimal,
    pub total_deductions: Decimal,
    pub total_net: Decimal,
    pub count: i64,
}

/// Hand-written SalarySlip SQL. Lives here (not in the write service) per the module's 4-layer rule:
/// services orchestrate and own the unit of work, repositories hold the SQL.
impl SalarySlipRepository {
    /// Insert an employee's slip into a run.
    ///
    /// Takes the CALLER'S connection so the slip and its lines commit as ONE unit. The caller has
    /// already relayed the ambient org request scope onto it (`bind_org_scope_on`, ADR-0029) so this
    /// passes the tenancy RLS fence — don't re-bind here.
    ///
    /// Returns the raw `sqlx::Error` deliberately: the caller inspects it for a unique violation to turn
    /// a second slip for the same employee in the same run into a domain error.
    pub async fn insert_slip(
        &self,
        conn: &mut sqlx::PgConnection,
        s: &NewSalarySlipRow,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO payroll.salary_slips
                 (id, payroll_entry_id, employee_id, structure_id, working_days, unpaid_days,
                  gross_pay, total_deductions, net_pay, overtime_hours, tax_method)
               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)"#,
        )
        .bind(s.id).bind(s.payroll_entry_id).bind(s.employee_id).bind(s.structure_id)
        .bind(s.working_days).bind(s.unpaid_days).bind(s.gross_pay).bind(s.total_deductions).bind(s.net_pay)
        .bind(s.overtime_hours).bind(s.tax_method.clone())
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Roll a run's live slips up into its gross/deduction/net totals, with the slip count.
    ///
    /// ID-only: the run id alone identifies the work, so this rides the request-dedicated
    /// connection's org request scope (ADR-0029). A caller driving its own transaction relays the
    /// ambient scope onto it first.
    pub async fn sum_totals_by_run(
        &self,
        pool: &PgPool,
        run_id: Uuid,
    ) -> Result<SlipTotalsRow, sqlx::Error> {
        let row = fetch_one_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT COALESCE(SUM(gross_pay),0) AS g, COALESCE(SUM(total_deductions),0) AS d,
                          COALESCE(SUM(net_pay),0) AS n, count(*) AS c
                   FROM payroll.salary_slips WHERE payroll_entry_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(run_id),
        )
        .await?;
        Ok(SlipTotalsRow {
            total_gross: row.get("g"), total_deductions: row.get("d"), total_net: row.get("n"),
            count: row.get("c"),
        })
    }
}

backbone_core::impl_crud_repository!(SalarySlipRepository, SalarySlip, soft_delete);