Skip to main content

backbone_accounting/application/service/
period_close_service.rs

1//! Fiscal-period close — zero the P&L accounts into Retained Earnings and lock the period.
2//!
3//! Hand-authored (user-owned; see `metaphor.codegen.yaml`). Application orchestration over the
4//! `PeriodCloseRepository` port (+ `PostingService` for the closing entry) — no `sqlx`/`PgPool`
5//! here. Proven by `tests/period_close_golden_cases.rs`.
6//!
7//! Tenancy (ADR-0029): the `company_id` parameter is the legacy twin — kept so unstripped
8//! callers compile and run unchanged; nothing here keys a statement on it (the adapters scope
9//! by the ambient org scope).
10
11use std::sync::Arc;
12
13use chrono::NaiveDate;
14use rust_decimal::Decimal;
15use serde::Serialize;
16use uuid::Uuid;
17
18use crate::application::service::posting_service::PostingService;
19use crate::domain::gl_posting::{PostingError, PostingLine, PostingRequest};
20use crate::domain::repositories::period_close_repository::PeriodCloseRepository;
21
22#[derive(Debug, Clone, Serialize)]
23pub struct PeriodCloseResult {
24    pub period_id: Uuid,
25    pub net_income: Decimal,
26    /// None when the period had no P&L activity (nothing to close).
27    pub closing_post_id: Option<Uuid>,
28    pub closing_journal_id: Option<Uuid>,
29}
30
31#[derive(Debug)]
32pub enum PeriodCloseError {
33    PeriodNotFound(Uuid),
34    AlreadyClosed,
35    /// Another close of the same period holds the claim.
36    CloseInProgress,
37    /// Lock and reopen act on a closed period only.
38    NotClosed { status: String },
39    /// A locked period is final: it is neither locked again nor reopened.
40    Locked,
41    /// Lock and reopen must say why.
42    ReasonRequired,
43    Posting(PostingError),
44    Internal(String),
45}
46impl std::fmt::Display for PeriodCloseError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            PeriodCloseError::PeriodNotFound(id) => write!(f, "period_not_found: {id}"),
50            PeriodCloseError::AlreadyClosed => write!(f, "period_already_closed"),
51            PeriodCloseError::CloseInProgress => write!(f, "period_close_in_progress"),
52            PeriodCloseError::NotClosed { status } => {
53                write!(f, "period_not_closed: the period is {status}; only a closed period can be locked or reopened")
54            }
55            PeriodCloseError::Locked => write!(f, "period_locked: a locked period is final"),
56            PeriodCloseError::ReasonRequired => write!(f, "reason_required: say why the period is locked or reopened"),
57            PeriodCloseError::Posting(e) => write!(f, "posting_error: {e}"),
58            PeriodCloseError::Internal(e) => write!(f, "internal_error: {e}"),
59        }
60    }
61}
62impl std::error::Error for PeriodCloseError {}
63impl From<PostingError> for PeriodCloseError {
64    fn from(e: PostingError) -> Self {
65        PeriodCloseError::Posting(e)
66    }
67}
68
69fn internal(e: anyhow::Error) -> PeriodCloseError {
70    PeriodCloseError::Internal(e.to_string())
71}
72
73#[derive(Clone)]
74pub struct PeriodCloseService {
75    repo: Arc<dyn PeriodCloseRepository>,
76    posting: PostingService,
77}
78
79impl PeriodCloseService {
80    pub fn new(
81        posting_repo: Arc<dyn crate::domain::repositories::posting_repository::PostingRepository>,
82        repo: Arc<dyn PeriodCloseRepository>,
83    ) -> Self {
84        Self {
85            posting: PostingService::new(posting_repo),
86            repo,
87        }
88    }
89
90    /// Arm the budget-control consult on the inner posting service (consuming
91    /// builder; `None` leaves it unarmed). The closing entry zeroes P&L
92    /// accounts against their normal balance, so control is usually left
93    /// unarmed here; the builder exists so hosts can choose otherwise.
94    pub fn with_budget_control_if_set(
95        mut self,
96        port: Option<std::sync::Arc<dyn crate::domain::repositories::BudgetControlPort>>,
97    ) -> Self {
98        self.posting = self.posting.with_budget_control_if_set(port);
99        self
100    }
101
102    /// Close `period_id`: post a closing entry that zeroes revenue/expense into
103    /// `retained_earnings_account_id`, then mark the period closed.
104    pub async fn close_period(
105        &self,
106        period_id: Uuid,
107        retained_earnings_account_id: Uuid,
108    ) -> Result<PeriodCloseResult, PeriodCloseError> {
109        self.close_period_as(period_id, retained_earnings_account_id, None).await
110    }
111
112    /// [`close_period`](Self::close_period), recording `actor` as the person
113    /// who started and finished the close.
114    ///
115    /// The period is claimed (`closing`) before the closing entry is posted,
116    /// so a second close of the same period is refused instead of posting a
117    /// second closing entry. If posting fails the claim is given back and the
118    /// period returns to the status it had.
119    pub async fn close_period_as(
120        &self,
121        period_id: Uuid,
122        retained_earnings_account_id: Uuid,
123        actor: Option<Uuid>,
124    ) -> Result<PeriodCloseResult, PeriodCloseError> {
125        let period = self.period(period_id).await?;
126        match period.status.as_str() {
127            "closed" | "locked" => return Err(PeriodCloseError::AlreadyClosed),
128            "closing" => return Err(PeriodCloseError::CloseInProgress),
129            _ => {}
130        }
131        let Some(prior_status) = self.repo.begin_close(period_id, actor).await.map_err(internal)? else {
132            // Lost the claim between the read and the write: say what won.
133            return Err(match self.period(period_id).await?.status.as_str() {
134                "closing" => PeriodCloseError::CloseInProgress,
135                _ => PeriodCloseError::AlreadyClosed,
136            });
137        };
138
139        match self.post_closing_entry(period_id, &period, retained_earnings_account_id).await {
140            Ok(result) => {
141                if !self.repo.finish_close(period_id, actor).await.map_err(internal)? {
142                    return Err(PeriodCloseError::Internal(
143                        "the period left `closing` while its close was posting".into(),
144                    ));
145                }
146                Ok(result)
147            }
148            Err(e) => {
149                self.repo.abort_close(period_id, &prior_status).await.map_err(internal)?;
150                Err(e)
151            }
152        }
153    }
154
155    /// Make a closed period final. A locked period refuses every posting and
156    /// cannot be reopened.
157    pub async fn lock_period(
158        &self,
159        period_id: Uuid,
160        actor: Option<Uuid>,
161        reason: &str,
162    ) -> Result<(), PeriodCloseError> {
163        let reason = required_reason(reason)?;
164        self.require_closed(period_id).await?;
165        if self.repo.lock(period_id, actor, reason).await.map_err(internal)? {
166            return Ok(());
167        }
168        // Another move won between the read and the write.
169        self.require_closed(period_id).await?;
170        Err(PeriodCloseError::Internal("the period could not be locked".into()))
171    }
172
173    /// Reopen a closed period so it takes postings again. The closing entry
174    /// already posted stays: a later close posts only what changed since.
175    pub async fn reopen_period(
176        &self,
177        period_id: Uuid,
178        actor: Option<Uuid>,
179        reason: &str,
180    ) -> Result<(), PeriodCloseError> {
181        let reason = required_reason(reason)?;
182        self.require_closed(period_id).await?;
183        if self.repo.reopen(period_id, actor, reason).await.map_err(internal)? {
184            return Ok(());
185        }
186        self.require_closed(period_id).await?;
187        Err(PeriodCloseError::Internal("the period could not be reopened".into()))
188    }
189
190    async fn period(
191        &self,
192        period_id: Uuid,
193    ) -> Result<crate::domain::repositories::period_close_repository::PeriodRow, PeriodCloseError> {
194        self.repo
195            .find_period(period_id)
196            .await
197            .map_err(internal)?
198            .ok_or(PeriodCloseError::PeriodNotFound(period_id))
199    }
200
201    async fn require_closed(&self, period_id: Uuid) -> Result<(), PeriodCloseError> {
202        match self.period(period_id).await?.status.as_str() {
203            "closed" => Ok(()),
204            "locked" => Err(PeriodCloseError::Locked),
205            other => Err(PeriodCloseError::NotClosed { status: other.to_string() }),
206        }
207    }
208
209    /// Post the entry that zeroes the period's P&L into retained earnings.
210    /// `closing_post_id` is `None` when the period had no P&L activity.
211    async fn post_closing_entry(
212        &self,
213        period_id: Uuid,
214        period: &crate::domain::repositories::period_close_repository::PeriodRow,
215        retained_earnings_account_id: Uuid,
216    ) -> Result<PeriodCloseResult, PeriodCloseError> {
217        let rows = self
218            .repo
219            .sum_pl_balances(period.start_date, period.end_date)
220            .await
221            .map_err(internal)?;
222
223        // Build closing lines: debit revenue balances, credit expense balances.
224        let mut lines: Vec<PostingLine> = Vec::new();
225        let mut revenue_total = Decimal::ZERO;
226        let mut expense_total = Decimal::ZERO;
227        for r in &rows {
228            match r.account_type.as_str() {
229                "revenue" | "other_income" => {
230                    let bal = r.credit - r.debit; // credit-normal balance
231                    if bal != Decimal::ZERO {
232                        revenue_total += bal;
233                        lines.push(close_line(r.account_id, bal, Decimal::ZERO));
234                    }
235                }
236                _ => {
237                    let bal = r.debit - r.credit; // debit-normal balance (expense/cogs/other_expense)
238                    if bal != Decimal::ZERO {
239                        expense_total += bal;
240                        lines.push(close_line(r.account_id, Decimal::ZERO, bal));
241                    }
242                }
243            }
244        }
245
246        let net_income = revenue_total - expense_total;
247
248        if lines.is_empty() {
249            return Ok(PeriodCloseResult {
250                period_id,
251                net_income,
252                closing_post_id: None,
253                closing_journal_id: None,
254            });
255        }
256
257        // Balancing line to Retained Earnings (equity, credit-normal): profit → credit, loss → debit.
258        if net_income > Decimal::ZERO {
259            lines.push(close_line(
260                retained_earnings_account_id,
261                Decimal::ZERO,
262                net_income,
263            ));
264        } else if net_income < Decimal::ZERO {
265            lines.push(close_line(
266                retained_earnings_account_id,
267                -net_income,
268                Decimal::ZERO,
269            ));
270        }
271
272        // Post the closing entry (period still open) through the GL-posting contract.
273        // The close posts through the shared posting request, which still carries the legacy
274        // company twin for unstripped consumers. Read it from the ambient org scope — the same
275        // source the module's repositories echo — rather than taking it from the caller.
276        let company_id = backbone_orm::org_scope::current_org_scope()
277            .and_then(|s| s.legacy_company_id())
278            .unwrap_or_default();
279        let mut req = PostingRequest::original(company_id, "manual", period_id, period.end_date);
280        req.description = Some("Period close".to_string());
281        // One closing entry per close cycle. Without a key the post dedups on
282        // its source (this period), so the close after a reopen would get the
283        // first close's entry back and leave the reopened P&L unclosed. The
284        // cycle is named by the last reopen, so a retry within one cycle still
285        // collapses onto the entry it already posted.
286        req.idempotency_key = Some(format!(
287            "period-close:{period_id}:{}",
288            period
289                .reopened_at
290                .map(|t| t.timestamp_micros().to_string())
291                .unwrap_or_else(|| "initial".to_string())
292        ));
293        req.lines = lines;
294        let result = self.posting.post(req, None).await?;
295
296        Ok(PeriodCloseResult {
297            period_id,
298            net_income,
299            closing_post_id: Some(result.post_id),
300            closing_journal_id: Some(result.journal_id),
301        })
302    }
303}
304
305/// A lock or reopen reason, trimmed; blank is refused.
306fn required_reason(reason: &str) -> Result<&str, PeriodCloseError> {
307    let reason = reason.trim();
308    if reason.is_empty() {
309        return Err(PeriodCloseError::ReasonRequired);
310    }
311    Ok(reason)
312}
313
314fn close_line(account_id: Uuid, debit: Decimal, credit: Decimal) -> PostingLine {
315    PostingLine {
316        account_id,
317        debit,
318        credit,
319        party_type: None,
320        party_id: None,
321        cost_center_id: None,
322        project_id: None,
323        department_id: None,
324        description: Some("Period close".to_string()),
325    }
326}