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    Posting(PostingError),
36    Internal(String),
37}
38impl std::fmt::Display for PeriodCloseError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            PeriodCloseError::PeriodNotFound(id) => write!(f, "period_not_found: {id}"),
42            PeriodCloseError::AlreadyClosed => write!(f, "period_already_closed"),
43            PeriodCloseError::Posting(e) => write!(f, "posting_error: {e}"),
44            PeriodCloseError::Internal(e) => write!(f, "internal_error: {e}"),
45        }
46    }
47}
48impl std::error::Error for PeriodCloseError {}
49impl From<PostingError> for PeriodCloseError {
50    fn from(e: PostingError) -> Self {
51        PeriodCloseError::Posting(e)
52    }
53}
54
55fn internal(e: anyhow::Error) -> PeriodCloseError {
56    PeriodCloseError::Internal(e.to_string())
57}
58
59#[derive(Clone)]
60pub struct PeriodCloseService {
61    repo: Arc<dyn PeriodCloseRepository>,
62    posting: PostingService,
63}
64
65impl PeriodCloseService {
66    pub fn new(
67        posting_repo: Arc<dyn crate::domain::repositories::posting_repository::PostingRepository>,
68        repo: Arc<dyn PeriodCloseRepository>,
69    ) -> Self {
70        Self {
71            posting: PostingService::new(posting_repo),
72            repo,
73        }
74    }
75
76    /// Arm the budget-control consult on the inner posting service (consuming
77    /// builder; `None` leaves it unarmed). The closing entry zeroes P&L
78    /// accounts against their normal balance, so control is usually left
79    /// unarmed here; the builder exists so hosts can choose otherwise.
80    pub fn with_budget_control_if_set(
81        mut self,
82        port: Option<std::sync::Arc<dyn crate::domain::repositories::BudgetControlPort>>,
83    ) -> Self {
84        self.posting = self.posting.with_budget_control_if_set(port);
85        self
86    }
87
88    /// Close `period_id`: post a closing entry that zeroes revenue/expense into
89    /// `retained_earnings_account_id`, then mark the period closed.
90    pub async fn close_period(
91        &self,
92        period_id: Uuid,
93        retained_earnings_account_id: Uuid,
94    ) -> Result<PeriodCloseResult, PeriodCloseError> {
95        let Some(period) = self
96            .repo
97            .find_period(period_id)
98            .await
99            .map_err(internal)?
100        else {
101            return Err(PeriodCloseError::PeriodNotFound(period_id));
102        };
103        if period.status == "closed" || period.status == "locked" {
104            return Err(PeriodCloseError::AlreadyClosed);
105        }
106
107        let rows = self
108            .repo
109            .sum_pl_balances(period.start_date, period.end_date)
110            .await
111            .map_err(internal)?;
112
113        // Build closing lines: debit revenue balances, credit expense balances.
114        let mut lines: Vec<PostingLine> = Vec::new();
115        let mut revenue_total = Decimal::ZERO;
116        let mut expense_total = Decimal::ZERO;
117        for r in &rows {
118            match r.account_type.as_str() {
119                "revenue" | "other_income" => {
120                    let bal = r.credit - r.debit; // credit-normal balance
121                    if bal != Decimal::ZERO {
122                        revenue_total += bal;
123                        lines.push(close_line(r.account_id, bal, Decimal::ZERO));
124                    }
125                }
126                _ => {
127                    let bal = r.debit - r.credit; // debit-normal balance (expense/cogs/other_expense)
128                    if bal != Decimal::ZERO {
129                        expense_total += bal;
130                        lines.push(close_line(r.account_id, Decimal::ZERO, bal));
131                    }
132                }
133            }
134        }
135
136        let net_income = revenue_total - expense_total;
137
138        if lines.is_empty() {
139            self.repo.mark_closed(period_id).await.map_err(internal)?;
140            return Ok(PeriodCloseResult {
141                period_id,
142                net_income,
143                closing_post_id: None,
144                closing_journal_id: None,
145            });
146        }
147
148        // Balancing line to Retained Earnings (equity, credit-normal): profit → credit, loss → debit.
149        if net_income > Decimal::ZERO {
150            lines.push(close_line(
151                retained_earnings_account_id,
152                Decimal::ZERO,
153                net_income,
154            ));
155        } else if net_income < Decimal::ZERO {
156            lines.push(close_line(
157                retained_earnings_account_id,
158                -net_income,
159                Decimal::ZERO,
160            ));
161        }
162
163        // Post the closing entry (period still open) through the GL-posting contract.
164        // The close posts through the shared posting request, which still carries the legacy
165        // company twin for unstripped consumers. Read it from the ambient org scope — the same
166        // source the module's repositories echo — rather than taking it from the caller.
167        let company_id = backbone_orm::org_scope::current_org_scope()
168            .and_then(|s| s.legacy_company_id())
169            .unwrap_or_default();
170        let mut req = PostingRequest::original(company_id, "manual", period_id, period.end_date);
171        req.description = Some("Period close".to_string());
172        req.lines = lines;
173        let result = self.posting.post(req, None).await?;
174
175        self.repo.mark_closed(period_id).await.map_err(internal)?;
176
177        Ok(PeriodCloseResult {
178            period_id,
179            net_income,
180            closing_post_id: Some(result.post_id),
181            closing_journal_id: Some(result.journal_id),
182        })
183    }
184}
185
186fn close_line(account_id: Uuid, debit: Decimal, credit: Decimal) -> PostingLine {
187    PostingLine {
188        account_id,
189        debit,
190        credit,
191        party_type: None,
192        party_id: None,
193        cost_center_id: None,
194        project_id: None,
195        department_id: None,
196        description: Some("Period close".to_string()),
197    }
198}