backbone-payroll 0.3.49

Payroll: salary structures, payroll runs and computed salary slips over effective-dated statutory tables, plus compensation changes
Documentation
//! SalarySlip REST handlers
//!
//! Generated by metaphor-schema. Do not edit manually.
//!
//! Uses Axum and backbone-core's BackboneCrudHandler for all 12 CRUD endpoints.

use std::collections::HashMap;
use std::sync::Arc;

use axum::Router;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use rust_decimal::Decimal;

// Backbone framework imports
use backbone_core::http::BackboneCrudHandler;

// Auth integration (optional)
#[cfg(feature = "auth")]
use backbone_auth::middleware::AuthContext;
#[cfg(feature = "auth")]
use backbone_auth::AuthMiddleware;

// Domain imports
use crate::domain::entity::*;
use crate::application::service::{SalarySlipService, ServiceError};

// DTO imports
use crate::presentation::dto::{CreateSalarySlipDto, UpdateSalarySlipDto, PatchSalarySlipDto, SalarySlipResponseDto};


/// Application error type
#[derive(Debug, thiserror::Error)]
pub enum SalarySlipError {
    #[error("Not found: {0}")]
    NotFound(String),
    #[error("Validation error: {0}")]
    Validation(String),
    #[error("Database error: {0}")]
    Database(String),
    #[error("Internal error: {0}")]
    Internal(String),
}

impl From<ServiceError> for SalarySlipError {
    fn from(err: ServiceError) -> Self {
        match err {
            ServiceError::NotFound => Self::NotFound(err.to_string()),
            ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
            ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
            ServiceError::Repository(ref e) => Self::Database(e.to_string()),
            ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
        }
    }
}

impl axum::response::IntoResponse for SalarySlipError {
    fn into_response(self) -> axum::response::Response {
        use axum::http::StatusCode;
        use axum::Json;

        let (status, code) = match &self {
            Self::NotFound(_) => (StatusCode::NOT_FOUND, "SALARYSLIP_NOT_FOUND"),
            Self::Validation(_) => (StatusCode::BAD_REQUEST, "SALARYSLIP_VALIDATION_ERROR"),
            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "SALARYSLIP_DATABASE_ERROR"),
            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "SALARYSLIP_INTERNAL_ERROR"),
        };

        let body = serde_json::json!({
            "success": false,
            "error": code,
            "message": self.to_string(),
        });

        (status, Json(body)).into_response()
    }
}

// =============================================================================
// Route Configuration
// =============================================================================

/// Create Axum router with all 16 Backbone endpoints for SalarySlip.
///
/// # Routes
///
/// | Method | Path | Description |
/// |--------|------|-------------|
/// | GET | /salary_slips | List with pagination |
/// | POST | /salary_slips | Create new |
/// | GET | /salary_slips/:id | Get by ID |
/// | PUT | /salary_slips/:id | Full update |
/// | PATCH | /salary_slips/:id | Partial update |
/// | DELETE | /salary_slips/:id | Soft delete |
/// | POST | /salary_slips/bulk | Bulk create |
/// | POST | /salary_slips/upsert | Upsert |
/// | GET | /salary_slips/trash | List deleted |
/// | POST | /salary_slips/:id/restore | Restore |
/// | DELETE | /salary_slips/empty | Empty trash |
/// | GET | /salary_slips/:id/deleted | Get deleted by ID |
/// | DELETE | /salary_slips/trash/:id | Permanent delete from trash |
/// | GET | /salary_slips/count | Count active entities |
/// | GET | /salary_slips/trash/count | Count deleted entities |
///
/// # Example
///
/// ```text
/// let service = Arc::new(SalarySlipService::with_repository(repository));
/// let router = create_salary_slip_routes(service);
/// ```
pub fn create_salary_slip_routes(service: Arc<SalarySlipService>) -> Router {
    BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::routes(
        service,
        "/salary_slips",
    )
}

/// Create Axum router with only the read (GET) endpoints for SalarySlip.
///
/// Safe for public, unauthenticated exposure (e.g., reference data).
/// Mutations must be served separately via `create_salary_slip_write_routes`,
/// typically wrapped in an auth middleware layer.
pub fn create_salary_slip_read_routes(service: Arc<SalarySlipService>) -> Router {
    BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::read_routes(
        service,
        "/salary_slips",
    )
}

/// Create Axum router with only the write (mutation) endpoints for SalarySlip.
///
/// These routes must NOT be publicly exposed. Wrap them with an auth
/// middleware before nesting into the application router.
///
/// # This is unguarded generic CRUD, not a validated write path
///
/// These are plain create/update/patch/delete mutations over the entity row —
/// they bypass all business invariants. If the module exposes a validated write
/// service (e.g. a command router over its domain engine), serve THAT instead
/// for any mutation that must respect domain rules.
pub fn create_salary_slip_write_routes(service: Arc<SalarySlipService>) -> Router {
    BackboneCrudHandler::<SalarySlipService, SalarySlip, CreateSalarySlipDto, UpdateSalarySlipDto, SalarySlipResponseDto>::write_routes(
        service,
        "/salary_slips",
    )
}

/// Create authenticated routes with auth middleware.
///
/// Requires the `auth` feature flag. The `AuthMiddleware` implementation
/// is responsible for extracting and validating tokens, then providing
/// an `AuthContext` via request extensions.
#[cfg(feature = "auth")]
pub fn create_protected_salary_slip_routes<A: AuthMiddleware + Send + Sync + 'static>(
    service: Arc<SalarySlipService>,
    auth: Arc<A>,
) -> Router {
    use axum::middleware;
    use axum::response::IntoResponse;

    let auth_layer = auth.clone();
    create_salary_slip_routes(service)
        .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
            let auth = auth_layer.clone();
            async move {
                let token = req.headers()
                    .get(axum::http::header::AUTHORIZATION)
                    .and_then(|h| h.to_str().ok())
                    .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
                    .unwrap_or("");
                match auth.authenticate(token).await {
                    Ok(ctx) => {
                        req.extensions_mut().insert(ctx);
                        next.run(req).await
                    }
                    Err(_) => {
                        (axum::http::StatusCode::UNAUTHORIZED,
                         axum::Json(serde_json::json!({
                             "success": false,
                             "error": "unauthorized",
                             "message": "Authentication required"
                         }))
                        ).into_response()
                    }
                }
            }
        }))
}

/// Query parameters of the nested subject-history route.
#[derive(serde::Deserialize)]
pub struct SalarySlipHistoryQuery {
    /// Reconstruct the row image at this instant instead of listing the trail.
    pub as_of: Option<chrono::DateTime<chrono::Utc>>,
}

/// The subject's audit history (ADR-0025 read surface): one entry per
/// captured change, newest first, positions numbered from the oldest by
/// (occurred_at, txid). `?as_of=` walks the trail forward from the
/// anchoring INSERT applying each diff's `to` values, so the row image at
/// any instant is reconstructible from the trail alone. Deleted subjects
/// still resolve history: the trail outlives the row (no FK, by contract).
pub async fn salary_slip_history(
    axum::extract::State(pool): axum::extract::State<sqlx::PgPool>,
    axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
    axum::extract::Query(q): axum::extract::Query<SalarySlipHistoryQuery>,
) -> axum::response::Response {
    use axum::http::StatusCode;
    use axum::response::IntoResponse;
    use serde_json::json;
    use sqlx::Row;
    let subject_id = id.to_string();
    let mut conn = match pool.acquire().await {
        Ok(c) => c,
        Err(e) => {
            return (
                StatusCode::SERVICE_UNAVAILABLE,
                axum::Json(json!({
                    "error": "history_pool_unavailable",
                    "message": e.to_string(),
                })),
            )
                .into_response()
        }
    };
    // Trail reads fence like business reads (ADR-0029 decorator): relay the
    // ambient org scope onto this self-opened connection.
    if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
        if let Err(e) = backbone_orm::org_scope::bind_org_scope_on(&mut conn, &scope).await {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                axum::Json(json!({
                    "error": "history_scope_bind",
                    "message": e.to_string(),
                })),
            )
                .into_response()
        }
    }
    const SUBJECT_TYPE: &str = "payroll.salary_slips";
    match q.as_of {
        None => {
            let rows = match sqlx::query("SELECT action, actor, changed, reason, occurred_at, txid, ROW_NUMBER() OVER (ORDER BY occurred_at, txid) AS position, COUNT(*) OVER () AS total FROM auditlog.audit_trails WHERE subject_type = $1 AND subject_id = $2 ORDER BY occurred_at DESC, txid DESC")
                .bind(SUBJECT_TYPE)
                .bind(&subject_id)
                .fetch_all(&mut *conn)
                .await
            {
                Ok(r) => r,
                Err(e) => {
                    return (
                        StatusCode::INTERNAL_SERVER_ERROR,
                        axum::Json(json!({
                            "error": "history_read",
                            "message": e.to_string(),
                        })),
                    )
                        .into_response()
                }
            };
            let total: i64 = rows.first().map(|r| r.get("total")).unwrap_or(0);
            let entries: Vec<serde_json::Value> = rows
                .iter()
                .map(|r| {
                    json!({
                        "position": r.get::<i64, _>("position"),
                        "action": r.get::<String, _>("action"),
                        "actor": r.get::<String, _>("actor"),
                        "changed": r.get::<serde_json::Value, _>("changed"),
                        "reason": r.get::<Option<String>, _>("reason"),
                        "occurred_at": r.get::<chrono::DateTime<chrono::Utc>, _>("occurred_at"),
                        "txid": r.get::<String, _>("txid"),
                    })
                })
                .collect();
            (
                StatusCode::OK,
                axum::Json(json!({
                    "subject_type": SUBJECT_TYPE,
                    "subject_id": subject_id,
                    "total": total,
                    "entries": entries,
                })),
            )
                .into_response()
        }
        Some(as_of) => {
            let rows = match sqlx::query("SELECT action, changed, occurred_at FROM auditlog.audit_trails WHERE subject_type = $1 AND subject_id = $2 AND occurred_at <= $3 ORDER BY occurred_at ASC, txid ASC")
                .bind(SUBJECT_TYPE)
                .bind(&subject_id)
                .bind(as_of)
                .fetch_all(&mut *conn)
                .await
            {
                Ok(r) => r,
                Err(e) => {
                    return (
                        StatusCode::INTERNAL_SERVER_ERROR,
                        axum::Json(json!({
                            "error": "history_read",
                            "message": e.to_string(),
                        })),
                    )
                        .into_response()
                }
            };
            if rows.is_empty() {
                return (
                    StatusCode::NOT_FOUND,
                    axum::Json(json!({
                        "error": "history_before_subject",
                        "message": "as_of precedes the subject's first captured event",
                    })),
                )
                    .into_response();
            }
            let mut image = serde_json::Map::new();
            let mut deleted_at: Option<chrono::DateTime<chrono::Utc>> = None;
            for r in &rows {
                let action: String = r.get("action");
                let changed: serde_json::Value = r.get("changed");
                if action == "delete" {
                    image.clear();
                    deleted_at = Some(r.get("occurred_at"));
                    continue;
                }
                deleted_at = None;
                if let Some(fields) = changed.as_object() {
                    for (field, diff) in fields {
                        if let Some(to) = diff.get("to") {
                            image.insert(field.clone(), to.clone());
                        }
                    }
                }
            }
            if let Some(at) = deleted_at {
                return (
                    StatusCode::NOT_FOUND,
                    axum::Json(json!({
                        "error": "subject_deleted_before_as_of",
                        "deleted_at": at,
                    })),
                )
                    .into_response();
            }
            (
                StatusCode::OK,
                axum::Json(json!({
                    "as_of": as_of,
                    "image": serde_json::Value::Object(image),
                })),
            )
                .into_response()
        }
    }
}

/// The nested history route, merged next to the model's CRUD router by the
/// module's route composer (the pool there is the tenant-resolved one).
pub fn create_salary_slip_history_route(pool: sqlx::PgPool) -> axum::Router {
    axum::Router::new()
        .route("/salary_slips/:id/history", axum::routing::get(salary_slip_history))
        .with_state(pool)
}