backbone_accounting/application/service/
journal_workflow_service.rs1use 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#[derive(Debug)]
24#[non_exhaustive]
25pub enum JournalWorkflowError {
26 NotFound(Uuid),
27 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#[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 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 pub async fn submit(
128 &self,
129 journal_id: Uuid,
130 ) -> Result<(), JournalWorkflowError> {
131 self.submit_as(journal_id, None).await
132 }
133
134 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 pub async fn approve(
155 &self,
156 journal_id: Uuid,
157 approved_by: Option<Uuid>,
158 ) -> Result<crate::domain::gl_posting::PostingResult, JournalWorkflowError> {
159 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 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 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 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(), 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 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}