backbone-accounting 0.11.2

GL, journals, ledger, reconciliation (extracted from bersihir)
Documentation
//! GLPosting flow implementation
//!
//! Generated by metaphor-schema
//!
//! Record a balanced AccountingPost into the General Ledger:
//! - idempotency check on (company_id, source_type, source_id, posting_type)
//! - validate R1..R5 (balanced, >=2 lines, postable account, party-for-AR/AP, open period)
//! - atomically write Journal + JournalLines + immutable Ledger rows (running balance)
//! - update account balances, mark the AccountingPost posted, emit AccountingPostPosted

use serde::{Deserialize, Serialize};
use std::sync::Arc;
use chrono;
use backbone_core::flow::{WorkflowStep, WorkflowContext};

/// Error type for flow execution
#[derive(Debug, Clone, thiserror::Error)]
pub enum FlowError {
    #[error("No current step to execute")]
    NoCurrentStep,

    #[error("Step execution failed: {0}")]
    StepFailed(String),

    #[error("Condition evaluation failed: {0}")]
    ConditionFailed(String),

    #[error("Compensation failed: {0}")]
    CompensationFailed(String),

    #[error("Flow timed out")]
    Timeout,

    #[error("Flow cancelled")]
    Cancelled,

    #[error("Invalid state transition: {from} -> {to}")]
    InvalidTransition { from: String, to: String },
}

/// Execution status for GLPosting flow
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GLPostingFlowStatus {
    /// Flow is pending execution
    Pending,
    /// Flow is currently running
    Running,
    /// Flow is waiting for an event or condition
    Waiting,
    /// Flow completed successfully
    Completed,
    /// Flow failed
    Failed,
    /// Flow was cancelled
    Cancelled,
    /// Flow is compensating (rolling back)
    Compensating,
}

/// Steps in GLPosting flow
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GLPostingFlowStep {
    /// idempotency_check
    IdempotencyCheck,
    /// validate
    Validate,
    /// write_journal
    WriteJournal,
    /// write_journal_lines
    WriteJournalLines,
    /// write_ledger
    WriteLedger,
    /// update_account_balances
    UpdateAccountBalances,
    /// mark_posted
    MarkPosted,
    /// posted
    Posted,
    /// rejected
    Rejected,
}

/// Instance of GLPosting flow execution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GLPostingFlowInstance {
    /// Unique instance ID
    pub id: String,
    /// Current status
    pub status: GLPostingFlowStatus,
    /// Current step
    pub current_step: Option<GLPostingFlowStep>,
    /// Flow context (variables)
    pub context: serde_json::Value,
    /// Completed steps
    pub completed_steps: Vec<GLPostingFlowStep>,
    /// Error if failed
    pub error: Option<String>,
    /// Created timestamp
    pub created_at: chrono::DateTime<chrono::Utc>,
    /// Updated timestamp
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

impl GLPostingFlowInstance {
    /// Create a new flow instance
    pub fn new(id: impl Into<String>) -> Self {
        let now = chrono::Utc::now();
        Self {
            id: id.into(),
            status: GLPostingFlowStatus::Pending,
            current_step: None,
            context: serde_json::json!({}),
            completed_steps: Vec::new(),
            error: None,
            created_at: now,
            updated_at: now,
        }
    }

    /// Check if flow is complete
    pub fn is_complete(&self) -> bool {
        matches!(
            self.status,
            GLPostingFlowStatus::Completed | GLPostingFlowStatus::Failed | GLPostingFlowStatus::Cancelled
        )
    }

    /// Check if flow is running
    pub fn is_running(&self) -> bool {
        matches!(
            self.status,
            GLPostingFlowStatus::Running | GLPostingFlowStatus::Waiting
        )
    }

    /// Set a context variable
    pub fn set_context(&mut self, key: &str, value: serde_json::Value) {
        if let serde_json::Value::Object(ref mut map) = self.context {
            map.insert(key.to_string(), value);
        }
        self.updated_at = chrono::Utc::now();
    }

    /// Get a context variable
    pub fn get_context(&self, key: &str) -> Option<&serde_json::Value> {
        if let serde_json::Value::Object(ref map) = self.context {
            map.get(key)
        } else {
            None
        }
    }
}

// Phase 1: FlowInstance satisfies backbone_core::flow::WorkflowContext.
// The entity() accessor requires the entity to be carried in the instance;
// add `pub entity: GLPosting` in the // <<< CUSTOM section and remove the todo!.
#[allow(unused_variables)]
impl WorkflowContext<GLPostingFlowInstance> for GLPostingFlowInstance {
    fn entity(&self) -> &GLPostingFlowInstance { self }
    fn set_var(&mut self, key: &str, value: serde_json::Value) {
        self.set_context(key, value);
    }
    fn get_var(&self, key: &str) -> Option<&serde_json::Value> {
        self.get_context(key)
    }
}

/// Step handler trait for GLPosting flow
#[async_trait::async_trait]
pub trait GLPostingStepHandler: Send + Sync {
    /// Handle idempotency_check step
    async fn handle_idempotency_check(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<Option<GLPostingFlowStep>, FlowError>;

    /// Handle validate step
    async fn handle_validate(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<Option<GLPostingFlowStep>, FlowError>;

    /// Handle write_journal step
    async fn handle_write_journal(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<Option<GLPostingFlowStep>, FlowError>;

    /// Handle write_journal_lines step
    async fn handle_write_journal_lines(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<Option<GLPostingFlowStep>, FlowError>;

    /// Handle write_ledger step
    async fn handle_write_ledger(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<Option<GLPostingFlowStep>, FlowError>;

    /// Handle update_account_balances step
    async fn handle_update_account_balances(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<Option<GLPostingFlowStep>, FlowError>;

    /// Handle mark_posted step
    async fn handle_mark_posted(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<Option<GLPostingFlowStep>, FlowError>;

    /// Handle terminal step posted
    async fn handle_posted(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<(), FlowError>;

    /// Handle terminal step rejected
    async fn handle_rejected(
        &self,
        instance: &mut GLPostingFlowInstance,
    ) -> Result<(), FlowError>;

}

/// Executor for GLPosting flow
pub struct GLPostingFlowExecutor<H: GLPostingStepHandler> {
    handler: Arc<H>,
}

impl<H: GLPostingStepHandler> GLPostingFlowExecutor<H> {
    /// Create a new flow executor
    pub fn new(handler: Arc<H>) -> Self {
        Self { handler }
    }

    /// Start a new flow instance
    pub async fn start(&self, instance_id: impl Into<String>) -> Result<GLPostingFlowInstance, FlowError> {
        let mut instance = GLPostingFlowInstance::new(instance_id);
        instance.status = GLPostingFlowStatus::Running;
        instance.current_step = Some(GLPostingFlowStep::IdempotencyCheck);
        Ok(instance)
    }

    /// Execute the current step
    pub async fn execute_step(&self, instance: &mut GLPostingFlowInstance) -> Result<(), FlowError> {
        let current_step = match instance.current_step {
            Some(step) => step,
            None => return Err(FlowError::NoCurrentStep),
        };

        let next_step = match current_step {
            GLPostingFlowStep::IdempotencyCheck => {
                self.handler.handle_idempotency_check(instance).await?
            }
            GLPostingFlowStep::Validate => {
                self.handler.handle_validate(instance).await?
            }
            GLPostingFlowStep::WriteJournal => {
                self.handler.handle_write_journal(instance).await?
            }
            GLPostingFlowStep::WriteJournalLines => {
                self.handler.handle_write_journal_lines(instance).await?
            }
            GLPostingFlowStep::WriteLedger => {
                self.handler.handle_write_ledger(instance).await?
            }
            GLPostingFlowStep::UpdateAccountBalances => {
                self.handler.handle_update_account_balances(instance).await?
            }
            GLPostingFlowStep::MarkPosted => {
                self.handler.handle_mark_posted(instance).await?
            }
            GLPostingFlowStep::Posted => {
                self.handler.handle_posted(instance).await?;
                None // Terminal step
            }
            GLPostingFlowStep::Rejected => {
                self.handler.handle_rejected(instance).await?;
                None // Terminal step
            }
        };

        // Mark current step as completed
        instance.completed_steps.push(current_step);

        // Move to next step
        match next_step {
            Some(next) => {
                instance.current_step = Some(next);
            }
            None => {
                instance.current_step = None;
                instance.status = GLPostingFlowStatus::Completed;
            }
        }

        instance.updated_at = chrono::Utc::now();
        Ok(())
    }

    /// Run the flow to completion
    pub async fn run(&self, instance: &mut GLPostingFlowInstance) -> Result<(), FlowError> {
        while !instance.is_complete() && instance.status != GLPostingFlowStatus::Waiting {
            self.execute_step(instance).await?;
        }
        Ok(())
    }

    /// Cancel the flow
    pub fn cancel(&self, instance: &mut GLPostingFlowInstance) {
        instance.status = GLPostingFlowStatus::Cancelled;
        instance.updated_at = chrono::Utc::now();
    }

    /// Mark flow as failed
    pub fn fail(&self, instance: &mut GLPostingFlowInstance, error: impl Into<String>) {
        instance.status = GLPostingFlowStatus::Failed;
        instance.error = Some(error.into());
        instance.updated_at = chrono::Utc::now();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_flow_instance_creation() {
        let instance = GLPostingFlowInstance::new("test-1");
        assert_eq!(instance.id, "test-1");
        assert_eq!(instance.status, GLPostingFlowStatus::Pending);
        assert!(!instance.is_complete());
    }

    #[test]
    fn test_flow_context() {
        let mut instance = GLPostingFlowInstance::new("test-2");
        instance.set_context("key", serde_json::json!("value"));
        let value = instance.get_context("key");
        assert_eq!(value, Some(&serde_json::json!("value")));
    }

    #[test]
    fn test_flow_status_transitions() {
        let mut instance = GLPostingFlowInstance::new("test-3");
        assert!(!instance.is_running());

        instance.status = GLPostingFlowStatus::Running;
        assert!(instance.is_running());
        assert!(!instance.is_complete());

        instance.status = GLPostingFlowStatus::Completed;
        assert!(instance.is_complete());
        assert!(!instance.is_running());
    }
}