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 CloseInProgress,
37 NotClosed { status: String },
39 Locked,
41 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 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 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 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 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 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 self.require_closed(period_id).await?;
170 Err(PeriodCloseError::Internal("the period could not be locked".into()))
171 }
172
173 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 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 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; 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; 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 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 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 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
305fn 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}