Skip to main content

backbone_accounting/application/service/
journal_workflow_service.rs

1//! Manual-journal approval workflow — the draft → submit → approve → post → void lifecycle.
2//!
3//! Hand-authored (user-owned; survives regeneration). Application orchestration over two ports:
4//! `JournalWorkflowRepository` for the journal status machine, and `PostingService` (backed by
5//! `PostingRepository`) for the actual ledger write. No `sqlx` / `PgPool` here.
6//!
7//! See `docs/business-flows/gl-posting.md` (manual-journal flow) and BRD §3.
8//!
9//! Tenancy (ADR-0029): no call here threads a tenant. The composing service's decorator
10//! scopes every read and write from the request's own scope.
11
12use std::sync::Arc;
13
14use chrono::Utc;
15use uuid::Uuid;
16
17use crate::application::service::posting_service::PostingService;
18use crate::domain::gl_posting::{PostingError, PostingRequest};
19use crate::domain::repositories::journal_workflow_repository::JournalWorkflowRepository;
20use crate::domain::repositories::posting_repository::PostingRepository;
21
22/// Typed workflow failure. `code()` is the stable error string.
23#[derive(Debug)]
24pub enum JournalWorkflowError {
25    NotFound(Uuid),
26    InvalidState {
27        id: Uuid,
28        current: String,
29        expected: &'static str,
30    },
31    NotPosted(Uuid),
32    Posting(PostingError),
33    Internal(String),
34}
35
36impl JournalWorkflowError {
37    pub fn code(&self) -> &'static str {
38        match self {
39            Self::NotFound(_) => "journal_not_found",
40            Self::InvalidState { .. } => "invalid_journal_state",
41            Self::NotPosted(_) => "journal_not_posted",
42            Self::Posting(_) => "posting_error",
43            Self::Internal(_) => "internal_error",
44        }
45    }
46    pub fn http_status(&self) -> u16 {
47        match self {
48            Self::NotFound(_) => 404,
49            Self::Internal(_) => 500,
50            Self::Posting(e) => e.http_status(),
51            _ => 422,
52        }
53    }
54}
55
56impl std::fmt::Display for JournalWorkflowError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::NotFound(id) => write!(f, "journal_not_found: {id}"),
60            Self::InvalidState {
61                id,
62                current,
63                expected,
64            } => {
65                write!(
66                    f,
67                    "invalid_journal_state: {id} is '{current}', expected '{expected}'"
68                )
69            }
70            Self::NotPosted(id) => write!(f, "journal_not_posted: {id}"),
71            Self::Posting(e) => write!(f, "posting_error: {e}"),
72            Self::Internal(e) => write!(f, "internal_error: {e}"),
73        }
74    }
75}
76impl std::error::Error for JournalWorkflowError {}
77impl From<PostingError> for JournalWorkflowError {
78    fn from(e: PostingError) -> Self {
79        Self::Posting(e)
80    }
81}
82
83fn internal(e: anyhow::Error) -> JournalWorkflowError {
84    JournalWorkflowError::Internal(e.to_string())
85}
86
87/// The manual-journal workflow service.
88#[derive(Clone)]
89pub struct JournalWorkflowService {
90    workflow: Arc<dyn JournalWorkflowRepository>,
91    posting: PostingService,
92}
93
94impl JournalWorkflowService {
95    pub fn new(
96        posting_repo: Arc<dyn PostingRepository>,
97        workflow: Arc<dyn JournalWorkflowRepository>,
98    ) -> Self {
99        Self {
100            posting: PostingService::new(posting_repo),
101            workflow,
102        }
103    }
104
105    /// Arm the budget-control consult on the inner posting service (consuming
106    /// builder; `None` leaves it unarmed). Hosts that compose a budget module
107    /// thread the port here so journal-approval posts are controlled too.
108    pub fn with_budget_control_if_set(
109        mut self,
110        port: Option<std::sync::Arc<dyn crate::domain::repositories::BudgetControlPort>>,
111    ) -> Self {
112        self.posting = self.posting.with_budget_control_if_set(port);
113        self
114    }
115
116    /// `draft → pending_approval`. Rejects if the journal is not `draft`.
117    pub async fn submit(
118        &self,
119        journal_id: Uuid,
120    ) -> Result<(), JournalWorkflowError> {
121        let ok = self
122            .workflow
123            .submit(journal_id)
124            .await
125            .map_err(internal)?;
126        if !ok {
127            return Err(self.state_error(journal_id, "draft").await);
128        }
129        Ok(())
130    }
131
132    /// `pending_approval → approved`, then post the journal through `PostingService` (writes the
133    /// ledger, flips the journal to `posted`). Returns the posting result.
134    pub async fn approve(
135        &self,
136        journal_id: Uuid,
137        approved_by: Option<Uuid>,
138    ) -> Result<crate::domain::gl_posting::PostingResult, JournalWorkflowError> {
139        let now = Utc::now();
140        let ok = self
141            .workflow
142            .approve(journal_id, approved_by, now)
143            .await
144            .map_err(internal)?;
145        if !ok {
146            return Err(self
147                .state_error(journal_id, "pending_approval")
148                .await);
149        }
150        Ok(self
151            .posting
152            .post_journal(
153                journal_id,
154                backbone_orm::org_scope::current_org_scope()
155                    .and_then(|sc| sc.legacy_company_id())
156                    .unwrap_or_default(),
157                approved_by,
158            )
159            .await?)
160    }
161
162    /// `draft|pending_approval → rejected` with a reason. Writes no ledger rows.
163    pub async fn reject(
164        &self,
165        journal_id: Uuid,
166        reason: String,
167        rejected_by: Option<Uuid>,
168    ) -> Result<(), JournalWorkflowError> {
169        let now = Utc::now();
170        let ok = self
171            .workflow
172            .reject(journal_id, &reason, rejected_by, now)
173            .await
174            .map_err(internal)?;
175        if !ok {
176            return Err(self
177                .state_error(journal_id, "draft or pending_approval")
178                .await);
179        }
180        Ok(())
181    }
182
183    /// `posted → voided`: posts a reversal through `PostingService`, then stamps the journal voided.
184    pub async fn void(
185        &self,
186        journal_id: Uuid,
187        voided_by: Option<Uuid>,
188        reason: String,
189    ) -> Result<crate::domain::gl_posting::PostingResult, JournalWorkflowError> {
190        let Some(status) = self
191            .workflow
192            .find_status(journal_id)
193            .await
194            .map_err(internal)?
195        else {
196            return Err(JournalWorkflowError::NotFound(journal_id));
197        };
198        if status.status != "posted" {
199            return Err(JournalWorkflowError::InvalidState {
200                id: journal_id,
201                current: status.status,
202                expected: "posted",
203            });
204        }
205
206        let Some(orig_post_id) = self
207            .workflow
208            .original_post(journal_id)
209            .await
210            .map_err(internal)?
211        else {
212            return Err(JournalWorkflowError::NotPosted(journal_id));
213        };
214
215        // The posting request still carries the legacy company twin for unstripped consumers;
216        // read it from the ambient org scope rather than taking it from the caller.
217        let req = PostingRequest {
218            company_id: backbone_orm::org_scope::current_org_scope()
219                .and_then(|sc| sc.legacy_company_id())
220                .unwrap_or_default(),
221            branch_id: None,
222            source_type: "manual".to_string(),
223            source_id: journal_id,
224            source_reference: None,
225            posting_date: Utc::now().date_naive(),
226            currency: status.currency,
227            posting_type: "reversal".to_string(),
228            reverses_post_id: Some(orig_post_id),
229            description: Some(format!("Void of journal {journal_id}: {reason}")),
230            lines: Vec::new(), // populated by the reversal source read inside post()
231            idempotency_key: Some(format!("void:{journal_id}")),
232        };
233        let result = self.posting.post(req, voided_by).await?;
234
235        let now = Utc::now();
236        self.workflow
237            .mark_voided(journal_id, voided_by, &reason, now)
238            .await
239            .map_err(internal)?;
240        Ok(result)
241    }
242
243    /// Resolve a falsy transition into a precise error: not-found vs wrong-state.
244    async fn state_error(
245        &self,
246        journal_id: Uuid,
247        expected: &'static str,
248    ) -> JournalWorkflowError {
249        match self.workflow.current_status(journal_id).await {
250            Ok(None) => JournalWorkflowError::NotFound(journal_id),
251            Ok(Some(current)) => JournalWorkflowError::InvalidState {
252                id: journal_id,
253                current,
254                expected,
255            },
256            Err(e) => internal(e),
257        }
258    }
259}