use std::sync::Arc;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::Serialize;
use uuid::Uuid;
use crate::application::service::posting_service::PostingService;
use crate::domain::gl_posting::{PostingError, PostingLine, PostingRequest};
use crate::domain::repositories::period_close_repository::PeriodCloseRepository;
#[derive(Debug, Clone, Serialize)]
pub struct PeriodCloseResult {
pub period_id: Uuid,
pub net_income: Decimal,
pub closing_post_id: Option<Uuid>,
pub closing_journal_id: Option<Uuid>,
}
#[derive(Debug)]
pub enum PeriodCloseError {
PeriodNotFound(Uuid),
AlreadyClosed,
CloseInProgress,
NotClosed { status: String },
Locked,
ReasonRequired,
Posting(PostingError),
Internal(String),
}
impl std::fmt::Display for PeriodCloseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PeriodCloseError::PeriodNotFound(id) => write!(f, "period_not_found: {id}"),
PeriodCloseError::AlreadyClosed => write!(f, "period_already_closed"),
PeriodCloseError::CloseInProgress => write!(f, "period_close_in_progress"),
PeriodCloseError::NotClosed { status } => {
write!(f, "period_not_closed: the period is {status}; only a closed period can be locked or reopened")
}
PeriodCloseError::Locked => write!(f, "period_locked: a locked period is final"),
PeriodCloseError::ReasonRequired => write!(f, "reason_required: say why the period is locked or reopened"),
PeriodCloseError::Posting(e) => write!(f, "posting_error: {e}"),
PeriodCloseError::Internal(e) => write!(f, "internal_error: {e}"),
}
}
}
impl std::error::Error for PeriodCloseError {}
impl From<PostingError> for PeriodCloseError {
fn from(e: PostingError) -> Self {
PeriodCloseError::Posting(e)
}
}
fn internal(e: anyhow::Error) -> PeriodCloseError {
PeriodCloseError::Internal(e.to_string())
}
#[derive(Clone)]
pub struct PeriodCloseService {
repo: Arc<dyn PeriodCloseRepository>,
posting: PostingService,
}
impl PeriodCloseService {
pub fn new(
posting_repo: Arc<dyn crate::domain::repositories::posting_repository::PostingRepository>,
repo: Arc<dyn PeriodCloseRepository>,
) -> Self {
Self {
posting: PostingService::new(posting_repo),
repo,
}
}
pub fn with_budget_control_if_set(
mut self,
port: Option<std::sync::Arc<dyn crate::domain::repositories::BudgetControlPort>>,
) -> Self {
self.posting = self.posting.with_budget_control_if_set(port);
self
}
pub async fn close_period(
&self,
period_id: Uuid,
retained_earnings_account_id: Uuid,
) -> Result<PeriodCloseResult, PeriodCloseError> {
self.close_period_as(period_id, retained_earnings_account_id, None).await
}
pub async fn close_period_as(
&self,
period_id: Uuid,
retained_earnings_account_id: Uuid,
actor: Option<Uuid>,
) -> Result<PeriodCloseResult, PeriodCloseError> {
let period = self.period(period_id).await?;
match period.status.as_str() {
"closed" | "locked" => return Err(PeriodCloseError::AlreadyClosed),
"closing" => return Err(PeriodCloseError::CloseInProgress),
_ => {}
}
let Some(prior_status) = self.repo.begin_close(period_id, actor).await.map_err(internal)? else {
return Err(match self.period(period_id).await?.status.as_str() {
"closing" => PeriodCloseError::CloseInProgress,
_ => PeriodCloseError::AlreadyClosed,
});
};
match self.post_closing_entry(period_id, &period, retained_earnings_account_id).await {
Ok(result) => {
if !self.repo.finish_close(period_id, actor).await.map_err(internal)? {
return Err(PeriodCloseError::Internal(
"the period left `closing` while its close was posting".into(),
));
}
Ok(result)
}
Err(e) => {
self.repo.abort_close(period_id, &prior_status).await.map_err(internal)?;
Err(e)
}
}
}
pub async fn lock_period(
&self,
period_id: Uuid,
actor: Option<Uuid>,
reason: &str,
) -> Result<(), PeriodCloseError> {
let reason = required_reason(reason)?;
self.require_closed(period_id).await?;
if self.repo.lock(period_id, actor, reason).await.map_err(internal)? {
return Ok(());
}
self.require_closed(period_id).await?;
Err(PeriodCloseError::Internal("the period could not be locked".into()))
}
pub async fn reopen_period(
&self,
period_id: Uuid,
actor: Option<Uuid>,
reason: &str,
) -> Result<(), PeriodCloseError> {
let reason = required_reason(reason)?;
self.require_closed(period_id).await?;
if self.repo.reopen(period_id, actor, reason).await.map_err(internal)? {
return Ok(());
}
self.require_closed(period_id).await?;
Err(PeriodCloseError::Internal("the period could not be reopened".into()))
}
async fn period(
&self,
period_id: Uuid,
) -> Result<crate::domain::repositories::period_close_repository::PeriodRow, PeriodCloseError> {
self.repo
.find_period(period_id)
.await
.map_err(internal)?
.ok_or(PeriodCloseError::PeriodNotFound(period_id))
}
async fn require_closed(&self, period_id: Uuid) -> Result<(), PeriodCloseError> {
match self.period(period_id).await?.status.as_str() {
"closed" => Ok(()),
"locked" => Err(PeriodCloseError::Locked),
other => Err(PeriodCloseError::NotClosed { status: other.to_string() }),
}
}
async fn post_closing_entry(
&self,
period_id: Uuid,
period: &crate::domain::repositories::period_close_repository::PeriodRow,
retained_earnings_account_id: Uuid,
) -> Result<PeriodCloseResult, PeriodCloseError> {
let rows = self
.repo
.sum_pl_balances(period.start_date, period.end_date)
.await
.map_err(internal)?;
let mut lines: Vec<PostingLine> = Vec::new();
let mut revenue_total = Decimal::ZERO;
let mut expense_total = Decimal::ZERO;
for r in &rows {
match r.account_type.as_str() {
"revenue" | "other_income" => {
let bal = r.credit - r.debit; if bal != Decimal::ZERO {
revenue_total += bal;
lines.push(close_line(r.account_id, bal, Decimal::ZERO));
}
}
_ => {
let bal = r.debit - r.credit; if bal != Decimal::ZERO {
expense_total += bal;
lines.push(close_line(r.account_id, Decimal::ZERO, bal));
}
}
}
}
let net_income = revenue_total - expense_total;
if lines.is_empty() {
return Ok(PeriodCloseResult {
period_id,
net_income,
closing_post_id: None,
closing_journal_id: None,
});
}
if net_income > Decimal::ZERO {
lines.push(close_line(
retained_earnings_account_id,
Decimal::ZERO,
net_income,
));
} else if net_income < Decimal::ZERO {
lines.push(close_line(
retained_earnings_account_id,
-net_income,
Decimal::ZERO,
));
}
let company_id = backbone_orm::org_scope::current_org_scope()
.and_then(|s| s.legacy_company_id())
.unwrap_or_default();
let mut req = PostingRequest::original(company_id, "manual", period_id, period.end_date);
req.description = Some("Period close".to_string());
req.idempotency_key = Some(format!(
"period-close:{period_id}:{}",
period
.reopened_at
.map(|t| t.timestamp_micros().to_string())
.unwrap_or_else(|| "initial".to_string())
));
req.lines = lines;
let result = self.posting.post(req, None).await?;
Ok(PeriodCloseResult {
period_id,
net_income,
closing_post_id: Some(result.post_id),
closing_journal_id: Some(result.journal_id),
})
}
}
fn required_reason(reason: &str) -> Result<&str, PeriodCloseError> {
let reason = reason.trim();
if reason.is_empty() {
return Err(PeriodCloseError::ReasonRequired);
}
Ok(reason)
}
fn close_line(account_id: Uuid, debit: Decimal, credit: Decimal) -> PostingLine {
PostingLine {
account_id,
debit,
credit,
party_type: None,
party_id: None,
cost_center_id: None,
project_id: None,
department_id: None,
description: Some("Period close".to_string()),
}
}