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 SalarySlipLine 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 SalarySlipLine SQL — including the deduction grouping the salary journal
//! is built from (4-layer rule: services orchestrate, repositories hold the SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<SalarySlipLine, 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 multi-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_all_rows_scoped;

use crate::domain::entity::SalarySlipLine;

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

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

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

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

/// The exact row one slip line writes.
///
/// Mirrors the raw column shape rather than the `SalarySlipLine` entity: `component_type` is cast at
/// the DB (`$4::component_type`), which is what lets a bad variant fail as a DB error instead of a
/// deserialize panic. `amount` is the service's already-prorated, money-rounded value.
pub struct NewSlipLineRow<'a> {
    pub id: Uuid,
    pub salary_slip_id: Uuid,
    pub name: &'a str,
    pub component_type: &'a str,
    pub is_statutory: bool,
    pub amount: Decimal,
    pub gl_account_id: Uuid,
    /// Provenance (NULL = structure-computed).
    pub source_kind: Option<&'static str>,
    pub source_ref: Option<Uuid>,
}

/// One deduction payable account's total across a run's slips.
///
/// `statutory` is `bool_or` over the group: it routes the settlement consumer's remittance to the right
/// authority, so an account carrying ANY statutory line counts as statutory.
pub struct DeductionGroupRow {
    pub gl_account_id: Uuid,
    pub amount: Decimal,
    pub statutory: bool,
}

/// Hand-written SalarySlipLine 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 SalarySlipLineRepository {
    /// Insert one line of a slip.
    ///
    /// Takes the CALLER'S connection so every line and its slip commit as ONE unit. The caller has
    /// already relayed the ambient org request scope onto it — don't re-bind here. The line's own org
    /// scoping rides its parent slip row (ADR-0029); the module carries no denormalized tenancy
    /// column.
    pub async fn insert_line(
        &self,
        conn: &mut sqlx::PgConnection,
        l: &NewSlipLineRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO payroll.salary_slip_lines
                 (id, salary_slip_id, name, component_type, is_statutory, amount, gl_account_id,
                  source_kind, source_ref)
               VALUES ($1,$2,$3,$4::component_type,$5,$6,$7,$8,$9)"#,
        )
        .bind(l.id).bind(l.salary_slip_id).bind(l.name).bind(l.component_type)
        .bind(l.is_statutory).bind(l.amount).bind(l.gl_account_id)
        .bind(l.source_kind).bind(l.source_ref)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Group a run's deductions by their payable account across every live slip — the credit side of the
    /// salary journal, and the same grouping that becomes `PayrollPosted`'s payable breakdown
    /// (settlement's input).
    ///
    /// A read outside any transaction: takes the pool and runs `fetch_all_rows_scoped` so the
    /// composing service's tenancy RLS fence applies (ADR-0029). The caller relays the ambient org
    /// request scope onto its own transaction first, or runs under HTTP where the
    /// request-dedicated connection already carries it.
    pub async fn group_deductions_by_account(
        &self,
        pool: &PgPool,
        run_id: Uuid,
    ) -> Result<Vec<DeductionGroupRow>, sqlx::Error> {
        let rows = fetch_all_rows_scoped(
            pool,
            sqlx::query(
                r#"SELECT l.gl_account_id, SUM(l.amount) AS amt, bool_or(l.is_statutory) AS statutory
                   FROM payroll.salary_slip_lines l JOIN payroll.salary_slips s ON s.id = l.salary_slip_id
                   WHERE s.payroll_entry_id=$1 AND l.component_type='deduction'::component_type
                     AND (s.metadata->>'deleted_at') IS NULL
                   GROUP BY l.gl_account_id"#,
            )
            .bind(run_id),
        )
        .await?;
        Ok(rows.iter().map(|r| DeductionGroupRow {
            gl_account_id: r.get("gl_account_id"), amount: r.get("amt"), statutory: r.get("statutory"),
        }).collect())
    }
}

backbone_core::impl_crud_repository!(SalarySlipLineRepository, SalarySlipLine, soft_delete);