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)]
24#[non_exhaustive]
25pub enum JournalWorkflowError {
26    NotFound(Uuid),
27    /// The approver submitted or created the journal, and the installation does not allow
28    /// self-approval (`accounting / journal_self_approval`).
29    SelfApproval(Uuid),
30    InvalidState {
31        id: Uuid,
32        current: String,
33        expected: &'static str,
34    },
35    NotPosted(Uuid),
36    Posting(PostingError),
37    Internal(String),
38}
39
40impl JournalWorkflowError {
41    pub fn code(&self) -> &'static str {
42        match self {
43            Self::NotFound(_) => "journal_not_found",
44            Self::SelfApproval(_) => "journal_self_approval",
45            Self::InvalidState { .. } => "invalid_journal_state",
46            Self::NotPosted(_) => "journal_not_posted",
47            Self::Posting(_) => "posting_error",
48            Self::Internal(_) => "internal_error",
49        }
50    }
51    pub fn http_status(&self) -> u16 {
52        match self {
53            Self::NotFound(_) => 404,
54            Self::SelfApproval(_) => 403,
55            Self::Internal(_) => 500,
56            Self::Posting(e) => e.http_status(),
57            _ => 422,
58        }
59    }
60}
61
62impl std::fmt::Display for JournalWorkflowError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::NotFound(id) => write!(f, "journal_not_found: {id}"),
66            Self::SelfApproval(id) => write!(
67                f,
68                "journal_self_approval: {id} was submitted or created by its approver; another person must approve it"
69            ),
70            Self::InvalidState {
71                id,
72                current,
73                expected,
74            } => {
75                write!(
76                    f,
77                    "invalid_journal_state: {id} is '{current}', expected '{expected}'"
78                )
79            }
80            Self::NotPosted(id) => write!(f, "journal_not_posted: {id}"),
81            Self::Posting(e) => write!(f, "posting_error: {e}"),
82            Self::Internal(e) => write!(f, "internal_error: {e}"),
83        }
84    }
85}
86impl std::error::Error for JournalWorkflowError {}
87impl From<PostingError> for JournalWorkflowError {
88    fn from(e: PostingError) -> Self {
89        Self::Posting(e)
90    }
91}
92
93fn internal(e: anyhow::Error) -> JournalWorkflowError {
94    JournalWorkflowError::Internal(e.to_string())
95}
96
97/// The manual-journal workflow service.
98#[derive(Clone)]
99pub struct JournalWorkflowService {
100    workflow: Arc<dyn JournalWorkflowRepository>,
101    posting: PostingService,
102}
103
104impl JournalWorkflowService {
105    pub fn new(
106        posting_repo: Arc<dyn PostingRepository>,
107        workflow: Arc<dyn JournalWorkflowRepository>,
108    ) -> Self {
109        Self {
110            posting: PostingService::new(posting_repo),
111            workflow,
112        }
113    }
114
115    /// Arm the budget-control consult on the inner posting service (consuming
116    /// builder; `None` leaves it unarmed). Hosts that compose a budget module
117    /// thread the port here so journal-approval posts are controlled too.
118    pub fn with_budget_control_if_set(
119        mut self,
120        port: Option<std::sync::Arc<dyn crate::domain::repositories::BudgetControlPort>>,
121    ) -> Self {
122        self.posting = self.posting.with_budget_control_if_set(port);
123        self
124    }
125
126    /// `draft → pending_approval`. Rejects if the journal is not `draft`.
127    pub async fn submit(
128        &self,
129        journal_id: Uuid,
130    ) -> Result<(), JournalWorkflowError> {
131        self.submit_as(journal_id, None).await
132    }
133
134    /// `draft → pending_approval`, recording who submitted it, which the self-approval rule
135    /// reads. Rejects if the journal is not `draft`.
136    pub async fn submit_as(
137        &self,
138        journal_id: Uuid,
139        submitted_by: Option<Uuid>,
140    ) -> Result<(), JournalWorkflowError> {
141        let ok = self
142            .workflow
143            .submit_as(journal_id, submitted_by)
144            .await
145            .map_err(internal)?;
146        if !ok {
147            return Err(self.state_error(journal_id, "draft").await);
148        }
149        Ok(())
150    }
151
152    /// `pending_approval → approved`, then post the journal through `PostingService` (writes the
153    /// ledger, flips the journal to `posted`). Returns the posting result.
154    pub async fn approve(
155        &self,
156        journal_id: Uuid,
157        approved_by: Option<Uuid>,
158    ) -> Result<crate::domain::gl_posting::PostingResult, JournalWorkflowError> {
159        // Segregation of duties: whoever submitted or created the journal does not approve it,
160        // unless the installation allows it.
161        if let Some(approver) = approved_by {
162            let authors = self.workflow.authors(journal_id).await.map_err(internal)?;
163            let own = authors.submitted_by == Some(approver) || authors.created_by == Some(approver);
164            if own && !self.workflow.self_approval_allowed().await.map_err(internal)? {
165                return Err(JournalWorkflowError::SelfApproval(journal_id));
166            }
167        }
168        let now = Utc::now();
169        let ok = self
170            .workflow
171            .approve(journal_id, approved_by, now)
172            .await
173            .map_err(internal)?;
174        if !ok {
175            return Err(self
176                .state_error(journal_id, "pending_approval")
177                .await);
178        }
179        Ok(self
180            .posting
181            .post_journal(
182                journal_id,
183                backbone_orm::org_scope::current_org_scope()
184                    .and_then(|sc| sc.legacy_company_id())
185                    .unwrap_or_default(),
186                approved_by,
187            )
188            .await?)
189    }
190
191    /// `draft|pending_approval → rejected` with a reason. Writes no ledger rows.
192    pub async fn reject(
193        &self,
194        journal_id: Uuid,
195        reason: String,
196        rejected_by: Option<Uuid>,
197    ) -> Result<(), JournalWorkflowError> {
198        let now = Utc::now();
199        let ok = self
200            .workflow
201            .reject(journal_id, &reason, rejected_by, now)
202            .await
203            .map_err(internal)?;
204        if !ok {
205            return Err(self
206                .state_error(journal_id, "draft or pending_approval")
207                .await);
208        }
209        Ok(())
210    }
211
212    /// `posted → voided`: posts a reversal through `PostingService`, then stamps the journal voided.
213    pub async fn void(
214        &self,
215        journal_id: Uuid,
216        voided_by: Option<Uuid>,
217        reason: String,
218    ) -> Result<crate::domain::gl_posting::PostingResult, JournalWorkflowError> {
219        let Some(status) = self
220            .workflow
221            .find_status(journal_id)
222            .await
223            .map_err(internal)?
224        else {
225            return Err(JournalWorkflowError::NotFound(journal_id));
226        };
227        if status.status != "posted" {
228            return Err(JournalWorkflowError::InvalidState {
229                id: journal_id,
230                current: status.status,
231                expected: "posted",
232            });
233        }
234
235        let Some(orig_post_id) = self
236            .workflow
237            .original_post(journal_id)
238            .await
239            .map_err(internal)?
240        else {
241            return Err(JournalWorkflowError::NotPosted(journal_id));
242        };
243
244        // The posting request still carries the legacy company twin for unstripped consumers;
245        // read it from the ambient org scope rather than taking it from the caller.
246        let req = PostingRequest {
247            company_id: backbone_orm::org_scope::current_org_scope()
248                .and_then(|sc| sc.legacy_company_id())
249                .unwrap_or_default(),
250            branch_id: None,
251            source_type: "manual".to_string(),
252            source_id: journal_id,
253            source_reference: None,
254            posting_date: Utc::now().date_naive(),
255            currency: status.currency,
256            posting_type: "reversal".to_string(),
257            reverses_post_id: Some(orig_post_id),
258            description: Some(format!("Void of journal {journal_id}: {reason}")),
259            lines: Vec::new(), // populated by the reversal source read inside post()
260            idempotency_key: Some(format!("void:{journal_id}")),
261        };
262        let result = self.posting.post(req, voided_by).await?;
263
264        let now = Utc::now();
265        self.workflow
266            .mark_voided(journal_id, voided_by, &reason, now)
267            .await
268            .map_err(internal)?;
269        Ok(result)
270    }
271
272    /// Resolve a falsy transition into a precise error: not-found vs wrong-state.
273    async fn state_error(
274        &self,
275        journal_id: Uuid,
276        expected: &'static str,
277    ) -> JournalWorkflowError {
278        match self.workflow.current_status(journal_id).await {
279            Ok(None) => JournalWorkflowError::NotFound(journal_id),
280            Ok(Some(current)) => JournalWorkflowError::InvalidState {
281                id: journal_id,
282                current,
283                expected,
284            },
285            Err(e) => internal(e),
286        }
287    }
288}