backbone-accounting 0.18.1

GL, journals, ledger, reconciliation (extracted from bersihir)
Documentation
//! Manual-journal approval workflow — the draft → submit → approve → post → void lifecycle.
//!
//! Hand-authored (user-owned; survives regeneration). Application orchestration over two ports:
//! `JournalWorkflowRepository` for the journal status machine, and `PostingService` (backed by
//! `PostingRepository`) for the actual ledger write. No `sqlx` / `PgPool` here.
//!
//! See `docs/business-flows/gl-posting.md` (manual-journal flow) and BRD §3.
//!
//! Tenancy (ADR-0029): no call here threads a tenant. The composing service's decorator
//! scopes every read and write from the request's own scope.

use std::sync::Arc;

use chrono::Utc;
use uuid::Uuid;

use crate::application::service::posting_service::PostingService;
use crate::domain::gl_posting::{PostingError, PostingRequest};
use crate::domain::repositories::journal_workflow_repository::JournalWorkflowRepository;
use crate::domain::repositories::posting_repository::PostingRepository;

/// Typed workflow failure. `code()` is the stable error string.
#[derive(Debug)]
#[non_exhaustive]
pub enum JournalWorkflowError {
    NotFound(Uuid),
    /// The approver submitted or created the journal, and the installation does not allow
    /// self-approval (`accounting / journal_self_approval`).
    SelfApproval(Uuid),
    InvalidState {
        id: Uuid,
        current: String,
        expected: &'static str,
    },
    NotPosted(Uuid),
    Posting(PostingError),
    Internal(String),
}

impl JournalWorkflowError {
    pub fn code(&self) -> &'static str {
        match self {
            Self::NotFound(_) => "journal_not_found",
            Self::SelfApproval(_) => "journal_self_approval",
            Self::InvalidState { .. } => "invalid_journal_state",
            Self::NotPosted(_) => "journal_not_posted",
            Self::Posting(_) => "posting_error",
            Self::Internal(_) => "internal_error",
        }
    }
    pub fn http_status(&self) -> u16 {
        match self {
            Self::NotFound(_) => 404,
            Self::SelfApproval(_) => 403,
            Self::Internal(_) => 500,
            Self::Posting(e) => e.http_status(),
            _ => 422,
        }
    }
}

impl std::fmt::Display for JournalWorkflowError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotFound(id) => write!(f, "journal_not_found: {id}"),
            Self::SelfApproval(id) => write!(
                f,
                "journal_self_approval: {id} was submitted or created by its approver; another person must approve it"
            ),
            Self::InvalidState {
                id,
                current,
                expected,
            } => {
                write!(
                    f,
                    "invalid_journal_state: {id} is '{current}', expected '{expected}'"
                )
            }
            Self::NotPosted(id) => write!(f, "journal_not_posted: {id}"),
            Self::Posting(e) => write!(f, "posting_error: {e}"),
            Self::Internal(e) => write!(f, "internal_error: {e}"),
        }
    }
}
impl std::error::Error for JournalWorkflowError {}
impl From<PostingError> for JournalWorkflowError {
    fn from(e: PostingError) -> Self {
        Self::Posting(e)
    }
}

fn internal(e: anyhow::Error) -> JournalWorkflowError {
    JournalWorkflowError::Internal(e.to_string())
}

/// The manual-journal workflow service.
#[derive(Clone)]
pub struct JournalWorkflowService {
    workflow: Arc<dyn JournalWorkflowRepository>,
    posting: PostingService,
}

impl JournalWorkflowService {
    pub fn new(
        posting_repo: Arc<dyn PostingRepository>,
        workflow: Arc<dyn JournalWorkflowRepository>,
    ) -> Self {
        Self {
            posting: PostingService::new(posting_repo),
            workflow,
        }
    }

    /// Arm the budget-control consult on the inner posting service (consuming
    /// builder; `None` leaves it unarmed). Hosts that compose a budget module
    /// thread the port here so journal-approval posts are controlled too.
    pub fn with_budget_control_if_set(
        mut self,
        port: Option<std::sync::Arc<dyn crate::domain::repositories::BudgetControlPort>>,
    ) -> Self {
        self.posting = self.posting.with_budget_control_if_set(port);
        self
    }

    /// `draft → pending_approval`. Rejects if the journal is not `draft`.
    pub async fn submit(
        &self,
        journal_id: Uuid,
    ) -> Result<(), JournalWorkflowError> {
        self.submit_as(journal_id, None).await
    }

    /// `draft → pending_approval`, recording who submitted it, which the self-approval rule
    /// reads. Rejects if the journal is not `draft`.
    pub async fn submit_as(
        &self,
        journal_id: Uuid,
        submitted_by: Option<Uuid>,
    ) -> Result<(), JournalWorkflowError> {
        let ok = self
            .workflow
            .submit_as(journal_id, submitted_by)
            .await
            .map_err(internal)?;
        if !ok {
            return Err(self.state_error(journal_id, "draft").await);
        }
        Ok(())
    }

    /// `pending_approval → approved`, then post the journal through `PostingService` (writes the
    /// ledger, flips the journal to `posted`). Returns the posting result.
    pub async fn approve(
        &self,
        journal_id: Uuid,
        approved_by: Option<Uuid>,
    ) -> Result<crate::domain::gl_posting::PostingResult, JournalWorkflowError> {
        // Segregation of duties: whoever submitted or created the journal does not approve it,
        // unless the installation allows it.
        if let Some(approver) = approved_by {
            let authors = self.workflow.authors(journal_id).await.map_err(internal)?;
            let own = authors.submitted_by == Some(approver) || authors.created_by == Some(approver);
            if own && !self.workflow.self_approval_allowed().await.map_err(internal)? {
                return Err(JournalWorkflowError::SelfApproval(journal_id));
            }
        }
        let now = Utc::now();
        let ok = self
            .workflow
            .approve(journal_id, approved_by, now)
            .await
            .map_err(internal)?;
        if !ok {
            return Err(self
                .state_error(journal_id, "pending_approval")
                .await);
        }
        Ok(self
            .posting
            .post_journal(
                journal_id,
                backbone_orm::org_scope::current_org_scope()
                    .and_then(|sc| sc.legacy_company_id())
                    .unwrap_or_default(),
                approved_by,
            )
            .await?)
    }

    /// `draft|pending_approval → rejected` with a reason. Writes no ledger rows.
    pub async fn reject(
        &self,
        journal_id: Uuid,
        reason: String,
        rejected_by: Option<Uuid>,
    ) -> Result<(), JournalWorkflowError> {
        let now = Utc::now();
        let ok = self
            .workflow
            .reject(journal_id, &reason, rejected_by, now)
            .await
            .map_err(internal)?;
        if !ok {
            return Err(self
                .state_error(journal_id, "draft or pending_approval")
                .await);
        }
        Ok(())
    }

    /// `posted → voided`: posts a reversal through `PostingService`, then stamps the journal voided.
    pub async fn void(
        &self,
        journal_id: Uuid,
        voided_by: Option<Uuid>,
        reason: String,
    ) -> Result<crate::domain::gl_posting::PostingResult, JournalWorkflowError> {
        let Some(status) = self
            .workflow
            .find_status(journal_id)
            .await
            .map_err(internal)?
        else {
            return Err(JournalWorkflowError::NotFound(journal_id));
        };
        if status.status != "posted" {
            return Err(JournalWorkflowError::InvalidState {
                id: journal_id,
                current: status.status,
                expected: "posted",
            });
        }

        let Some(orig_post_id) = self
            .workflow
            .original_post(journal_id)
            .await
            .map_err(internal)?
        else {
            return Err(JournalWorkflowError::NotPosted(journal_id));
        };

        // The posting request still carries the legacy company twin for unstripped consumers;
        // read it from the ambient org scope rather than taking it from the caller.
        let req = PostingRequest {
            company_id: backbone_orm::org_scope::current_org_scope()
                .and_then(|sc| sc.legacy_company_id())
                .unwrap_or_default(),
            branch_id: None,
            source_type: "manual".to_string(),
            source_id: journal_id,
            source_reference: None,
            posting_date: Utc::now().date_naive(),
            currency: status.currency,
            posting_type: "reversal".to_string(),
            reverses_post_id: Some(orig_post_id),
            description: Some(format!("Void of journal {journal_id}: {reason}")),
            lines: Vec::new(), // populated by the reversal source read inside post()
            idempotency_key: Some(format!("void:{journal_id}")),
        };
        let result = self.posting.post(req, voided_by).await?;

        let now = Utc::now();
        self.workflow
            .mark_voided(journal_id, voided_by, &reason, now)
            .await
            .map_err(internal)?;
        Ok(result)
    }

    /// Resolve a falsy transition into a precise error: not-found vs wrong-state.
    async fn state_error(
        &self,
        journal_id: Uuid,
        expected: &'static str,
    ) -> JournalWorkflowError {
        match self.workflow.current_status(journal_id).await {
            Ok(None) => JournalWorkflowError::NotFound(journal_id),
            Ok(Some(current)) => JournalWorkflowError::InvalidState {
                id: journal_id,
                current,
                expected,
            },
            Err(e) => internal(e),
        }
    }
}