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)]
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#[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 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 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 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 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 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 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(), 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 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}