Skip to main content

backbone_accounting/application/service/
posting_service.rs

1//! GL-posting service — the inbound port of the GL-posting contract.
2//!
3//! Hand-authored behavior (NOT generated). This is the **application** orchestration layer: it
4//! loads data via the `PostingRepository` port, validates with the pure domain rules
5//! (`domain::services::posting_rules`), commits atomically back through the port, and publishes
6//! contract events. It holds NO `PgPool` and runs NO `sqlx` — all persistence lives in
7//! `infrastructure::persistence::posting_repository::SqlxPostingRepository`.
8//!
9//! Implements double-entry posting per `docs/erp/gl-posting-contract.md` and the golden cases in
10//! `docs/business-flows/golden-cases.md`.
11//!
12//! Tenancy (ADR-0029): the module is tenant-agnostic — nothing here keys a statement on
13//! `company_id`. The `company_id` carried by `PostingRequest` is the documented legacy twin:
14//! unstripped producers still pass one and still get correct behavior. It rides along into the
15//! persistence port (whose adapter scopes every statement by the ambient org scope instead),
16//! the host seams (budget control, deferred tax), and the event payloads — never into a WHERE
17//! clause.
18//!
19//! This file is user-owned (see `metaphor.codegen.yaml`) and survives regeneration.
20
21use 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
32// The domain contract types are imported (and re-exported for call-site compatibility) from the
33// domain layer. `pub use` also brings them into local scope.
34pub use crate::domain::gl_posting::{
35    AccountingPostFailed, AccountingPostPosted, PostingError, PostingEvent, PostingEventSink,
36    PostingLine, PostingRequest, PostingResult,
37};
38
39/// Map a persistence (`anyhow`) failure into the posting error type.
40fn internal(e: anyhow::Error) -> PostingError {
41    PostingError::Internal(e.to_string())
42}
43
44/// Default sink — emits structured tracing events.
45pub 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/// The GL-posting service. Depends on the `PostingRepository` port (+ an event sink).
63#[derive(Clone)]
64pub struct PostingService {
65    repo: Arc<dyn PostingRepository>,
66    sink: Arc<dyn PostingEventSink>,
67    /// Host-implemented budget control, consulted after the pure rules pass.
68    /// `None` (default) = no budget module composed: no check, fail-open.
69    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    /// Construct with a custom event sink (real bus adapter or a test recorder).
82    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    /// Arm the budget-control consult on an already-built service (consuming
91    /// builder; `None` leaves it unarmed — the fail-open default for hosts
92    /// without a budget module).
93    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    /// Record a balanced posting. Idempotent on (company, source_type, source_id, posting_type)
102    /// — or on `idempotency_key` when the producer sets one.
103    pub async fn post(
104        &self,
105        mut req: PostingRequest,
106        posted_by: Option<uuid::Uuid>,
107    ) -> Result<PostingResult, PostingError> {
108        // Idempotency: return the existing posted entry for the same source identity.
109        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        // Reversal derives its (swapped) lines from the original journal.
129        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        // Validate; on failure record a failed AccountingPost (audit) and return the code.
136        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    /// Post an existing **approved** manual journal to the ledger. Idempotent: a journal already
181    /// `posted` returns its existing post. Used by the journal approval workflow (approve → post).
182    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        // Validate the stored lines (pure rules against loaded accounts + period-open flag).
220        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        // Budget control — AFTER the pure rules, so both entry points (post()
318        // and post_journal()) get it and the idempotent-reuse early return
319        // above correctly skips it (an already-posted entry is not re-judged).
320        // Unwired port = no budget module composed: no check (fail-open). A
321        // wired port that errors refuses the posting (fail-closed): a broken
322        // budget module must not silently disable block enforcement.
323        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    // ---- helpers ------------------------------------------------------------
355
356    /// Emit the posted event (only on a fresh commit, not an idempotent reuse).
357    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    /// Load the original journal's swapped reversal lines into `req.lines`; return the original
375    /// journal id (for the reversal links).
376    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}