1use std::sync::Arc;
22
23use chrono::{Datelike, Utc};
24use rust_decimal::Decimal;
25
26use crate::domain::repositories::posting_repository::{
27 FailedPost, LedgerEntryInput, ManualJournalCommit, PostingCommit, PostingRepository,
28 PostingWrite,
29};
30use crate::domain::services::posting_rules;
31
32pub use crate::domain::gl_posting::{
35 AccountingPostFailed, AccountingPostPosted, PostingError, PostingEvent, PostingEventSink,
36 PostingLine, PostingRequest, PostingResult,
37};
38
39fn internal(e: anyhow::Error) -> PostingError {
41 PostingError::Internal(e.to_string())
42}
43
44pub struct LoggingSink;
46
47impl PostingEventSink for LoggingSink {
48 fn publish(&self, event: PostingEvent) {
49 match &event {
50 PostingEvent::AccountingPostPosted(e) => tracing::info!(
51 target: "accounting.events", post_id = %e.post_id, journal_id = %e.journal_id,
52 source_type = %e.source_type, "AccountingPostPosted"
53 ),
54 PostingEvent::AccountingPostFailed(e) => tracing::warn!(
55 target: "accounting.events", source_type = %e.source_type, code = %e.error_code,
56 "AccountingPostFailed"
57 ),
58 }
59 }
60}
61
62#[derive(Clone)]
64pub struct PostingService {
65 repo: Arc<dyn PostingRepository>,
66 sink: Arc<dyn PostingEventSink>,
67 budget_control: Option<Arc<dyn crate::domain::repositories::BudgetControlPort>>,
70}
71
72impl PostingService {
73 pub fn new(repo: Arc<dyn PostingRepository>) -> Self {
74 Self {
75 repo,
76 sink: Arc::new(LoggingSink),
77 budget_control: None,
78 }
79 }
80
81 pub fn with_sink(repo: Arc<dyn PostingRepository>, sink: Arc<dyn PostingEventSink>) -> Self {
83 Self {
84 repo,
85 sink,
86 budget_control: None,
87 }
88 }
89
90 pub fn with_budget_control_if_set(
94 mut self,
95 port: Option<Arc<dyn crate::domain::repositories::BudgetControlPort>>,
96 ) -> Self {
97 self.budget_control = port;
98 self
99 }
100
101 pub async fn post(
104 &self,
105 mut req: PostingRequest,
106 posted_by: Option<uuid::Uuid>,
107 ) -> Result<PostingResult, PostingError> {
108 if let Some((post_id, journal_id)) = self
110 .repo
111 .find_existing_post(
112 &req.source_type,
113 req.source_id,
114 &req.posting_type,
115 req.idempotency_key.as_deref(),
116 )
117 .await
118 .map_err(internal)?
119 {
120 return Ok(PostingResult {
121 post_id,
122 journal_id,
123 posting_status: "posted".to_string(),
124 idempotent_reuse: true,
125 });
126 }
127
128 let reverses_journal_id = if req.posting_type == "reversal" {
130 Some(self.build_reversal_lines(&mut req).await?)
131 } else {
132 None
133 };
134
135 if let Err(e) = self.validate(&req).await {
137 let _ = self.record_failed(&req, &e).await;
138 return Err(e);
139 }
140
141 let now = Utc::now();
142 let fiscal_period_id = self
143 .repo
144 .find_period_id(req.posting_date)
145 .await
146 .map_err(internal)?;
147
148 let write = PostingWrite {
149 company_id: req.company_id,
150 branch_id: req.branch_id,
151 source_type: req.source_type.clone(),
152 source_id: req.source_id,
153 source_reference: req.source_reference.clone(),
154 posting_date: req.posting_date,
155 fiscal_period_id,
156 fiscal_year: req.posting_date.year(),
157 fiscal_month: req.posting_date.month() as i32,
158 currency: req.currency.clone(),
159 posting_type: req.posting_type.clone(),
160 reverses_post_id: req.reverses_post_id,
161 reverses_journal_id,
162 description: req.description.clone(),
163 idempotency_key: req.idempotency_key.clone(),
164 posted_by,
165 now,
166 lines: req.lines.clone(),
167 };
168
169 let commit = self.repo.commit_posting(write).await.map_err(internal)?;
170 self.emit(&req, &commit);
171
172 Ok(PostingResult {
173 post_id: commit.post_id,
174 journal_id: commit.journal_id,
175 posting_status: "posted".to_string(),
176 idempotent_reuse: commit.reused,
177 })
178 }
179
180 pub async fn post_journal(
183 &self,
184 journal_id: uuid::Uuid,
185 company_id: uuid::Uuid,
186 posted_by: Option<uuid::Uuid>,
187 ) -> Result<PostingResult, PostingError> {
188 let Some(ctx) = self
189 .repo
190 .find_manual_journal_for_post(journal_id)
191 .await
192 .map_err(internal)?
193 else {
194 return Err(PostingError::Conflict(format!(
195 "journal {journal_id} not found"
196 )));
197 };
198
199 if ctx.status == "posted" {
200 let existing = self
201 .repo
202 .existing_post_for_journal(journal_id)
203 .await
204 .map_err(internal)?;
205 return Ok(PostingResult {
206 post_id: existing.unwrap_or_default(),
207 journal_id,
208 posting_status: "posted".to_string(),
209 idempotent_reuse: true,
210 });
211 }
212 if ctx.status != "approved" {
213 return Err(PostingError::Conflict(format!(
214 "journal {journal_id} is '{}', must be 'approved' to post",
215 ctx.status
216 )));
217 }
218
219 let lines: Vec<PostingLine> = ctx.lines.iter().map(|(_, l)| l.clone()).collect();
221 let line_ids: Vec<uuid::Uuid> = lines.iter().map(|l| l.account_id).collect();
222 let val_req = PostingRequest {
223 company_id,
224 branch_id: ctx.branch_id,
225 source_type: ctx.source_type.clone(),
226 source_id: ctx.source_id,
227 source_reference: None,
228 posting_date: ctx.posting_date,
229 currency: ctx.currency.clone(),
230 posting_type: "original".to_string(),
231 reverses_post_id: None,
232 description: ctx.description.clone(),
233 lines: lines.clone(),
234 idempotency_key: None,
235 };
236 if let Err(e) = self.validate_lines(&val_req, &line_ids).await {
237 let _ = self.record_failed(&val_req, &e).await;
238 return Err(e);
239 }
240
241 let now = Utc::now();
242 let commit_input = ManualJournalCommit {
243 journal_id,
244 company_id,
245 branch_id: ctx.branch_id,
246 journal_number: ctx.journal_number,
247 posting_date: ctx.posting_date,
248 fiscal_period_id: ctx.fiscal_period_id,
249 fiscal_year: ctx.fiscal_year,
250 fiscal_month: ctx.fiscal_month,
251 currency: ctx.currency,
252 source_type: ctx.source_type.clone(),
253 source_id: ctx.source_id,
254 description: ctx.description.clone(),
255 posted_by,
256 now,
257 lines: ctx
258 .lines
259 .into_iter()
260 .map(|(id, line)| LedgerEntryInput {
261 journal_line_id: id,
262 line,
263 })
264 .collect(),
265 };
266
267 let commit = self
268 .repo
269 .commit_manual_journal(commit_input)
270 .await
271 .map_err(internal)?;
272
273 self.sink
274 .publish(PostingEvent::AccountingPostPosted(AccountingPostPosted {
275 post_id: commit.post_id,
276 journal_id: commit.journal_id,
277 company_id,
278 source_type: ctx.source_type,
279 source_id: ctx.source_id,
280 total_debit: commit.total_debit,
281 total_credit: commit.total_credit,
282 occurred_at: now,
283 }));
284
285 Ok(PostingResult {
286 post_id: commit.post_id,
287 journal_id: commit.journal_id,
288 posting_status: "posted".to_string(),
289 idempotent_reuse: commit.reused,
290 })
291 }
292
293 async fn validate(&self, req: &PostingRequest) -> Result<(), PostingError> {
294 let ids: Vec<uuid::Uuid> = req.lines.iter().map(|l| l.account_id).collect();
295 self.validate_lines(req, &ids).await
296 }
297
298 async fn validate_lines(
299 &self,
300 req: &PostingRequest,
301 ids: &[uuid::Uuid],
302 ) -> Result<(), PostingError> {
303 let rows = self
304 .repo
305 .find_postable_accounts(ids)
306 .await
307 .map_err(internal)?;
308 let accounts: std::collections::HashMap<uuid::Uuid, _> =
309 rows.into_iter().map(|a| (a.id, a)).collect();
310 let period_closed = self
311 .repo
312 .is_period_closed(req.posting_date)
313 .await
314 .map_err(internal)?;
315 posting_rules::validate(&req.lines, &accounts, period_closed)?;
316
317 if let Some(port) = self.budget_control.as_ref() {
324 let breaches = port
325 .evaluate_posting(req.posting_date, &req.lines)
326 .await
327 .map_err(internal)?;
328 match posting_rules::budget_outcome(&breaches) {
329 posting_rules::BudgetOutcome::WithinBudget => {}
330 posting_rules::BudgetOutcome::Warned(breaches) => {
331 for b in &breaches {
332 tracing::warn!(
333 target: "accounting.events",
334 budget_id = %b.budget_id,
335 budget_line_id = %b.budget_line_id,
336 account_id = %b.account_id,
337 cost_center_id = ?b.cost_center_id,
338 fiscal_period_id = %b.fiscal_period_id,
339 planned = %b.planned_amount,
340 achieved = %b.achieved_amount,
341 pending = %b.pending_amount,
342 "budget_exceeded_warn"
343 );
344 }
345 }
346 posting_rules::BudgetOutcome::Blocked(breaches) => {
347 return Err(PostingError::BudgetExceeded(breaches));
348 }
349 }
350 }
351 Ok(())
352 }
353
354 fn emit(&self, req: &PostingRequest, commit: &PostingCommit) {
358 if commit.reused {
359 return;
360 }
361 self.sink
362 .publish(PostingEvent::AccountingPostPosted(AccountingPostPosted {
363 post_id: commit.post_id,
364 journal_id: commit.journal_id,
365 company_id: req.company_id,
366 source_type: req.source_type.clone(),
367 source_id: req.source_id,
368 total_debit: commit.total_debit,
369 total_credit: commit.total_credit,
370 occurred_at: Utc::now(),
371 }));
372 }
373
374 async fn build_reversal_lines(
377 &self,
378 req: &mut PostingRequest,
379 ) -> Result<uuid::Uuid, PostingError> {
380 let orig_post_id = req
381 .reverses_post_id
382 .ok_or_else(|| PostingError::Conflict("reversal requires reverses_post_id".into()))?;
383 let source = self
384 .repo
385 .find_reversal_source(orig_post_id)
386 .await
387 .map_err(internal)?
388 .ok_or_else(|| {
389 PostingError::Conflict("original posting not found or not posted".into())
390 })?;
391 req.lines = source.lines;
392 Ok(source.journal_id)
393 }
394
395 async fn record_failed(
396 &self,
397 req: &PostingRequest,
398 err: &PostingError,
399 ) -> Result<(), PostingError> {
400 let total_debit: Decimal = req.lines.iter().map(|l| l.debit).sum();
401 let total_credit: Decimal = req.lines.iter().map(|l| l.credit).sum();
402 let now = Utc::now();
403 self.repo
404 .record_failed(FailedPost {
405 company_id: req.company_id,
406 branch_id: req.branch_id,
407 source_type: req.source_type.clone(),
408 source_id: req.source_id,
409 source_reference: req.source_reference.clone(),
410 posting_type: req.posting_type.clone(),
411 currency: req.currency.clone(),
412 total_debit,
413 total_credit,
414 failed_at: now,
415 error_code: err.code().to_string(),
416 error_message: err.to_string(),
417 })
418 .await
419 .map_err(internal)?;
420 self.sink
421 .publish(PostingEvent::AccountingPostFailed(AccountingPostFailed {
422 company_id: req.company_id,
423 source_type: req.source_type.clone(),
424 source_id: req.source_id,
425 error_code: err.code().to_string(),
426 error_message: err.to_string(),
427 occurred_at: now,
428 }));
429 Ok(())
430 }
431}