backbone_accounting/application/service/
period_close_service.rs1use 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 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 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 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 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; 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; 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 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 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}