Files
Distribution_GL/Services/JournalEntryService.cs
T

289 lines
12 KiB
C#

using General_Ledger_Service.DTOs.Common;
using General_Ledger_Service.DTOs.JournalEntries;
using General_Ledger_Service.Enums;
using General_Ledger_Service.Exceptions;
using General_Ledger_Service.Infra.UoW;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Interfaces.Services;
using General_Ledger_Service.Models;
namespace General_Ledger_Service.Services
{
// Retrofit - the client always sends a complete, ready-to-post journal entry in one call;
// there is no client-facing DRAFT state anymore. CreateAsync validates, inserts, balance-checks,
// period-lock-checks, and posts, all in one transaction. lines is required for a plain create
// and for a PartialAdjustment correction (previously deferred to a separate SetLines call);
// FullReversal still auto-builds its lines by mirroring the original.
public class JournalEntryService(
IJournalEntryRepository journalEntryRepository,
IAccountRepository accountRepository,
ICostCenterRepository costCenterRepository,
IFiscalCalendarRepository fiscalCalendarRepository,
IAuditLogRepository auditLogRepository,
IUnitOfWork unitOfWork) : IJournalEntryService
{
private readonly IJournalEntryRepository _journalEntryRepository = journalEntryRepository;
private readonly IAccountRepository _accountRepository = accountRepository;
private readonly ICostCenterRepository _costCenterRepository = costCenterRepository;
private readonly IFiscalCalendarRepository _fiscalCalendarRepository = fiscalCalendarRepository;
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
private readonly IUnitOfWork _unitOfWork = unitOfWork;
public async Task<ApiResponse> CreateAsync(CreateJournalEntryRequest request)
{
// Idempotency guard for service-to-service callers: the same sourceModule + reference is only ever
// posted once, so a retry after a timeout returns the existing entry instead of double-posting.
if (request.Correction is null
&& !string.IsNullOrWhiteSpace(request.SourceModule) && !string.IsNullOrWhiteSpace(request.Reference))
{
var existing = await _journalEntryRepository.GetJournalEntryBySourceReferenceAsync(request.SourceModule, request.Reference);
if (existing is not null)
{
return ResponseHelpers.Ok("Journal entry already exists for this sourceModule and reference", existing);
}
}
var period = await _fiscalCalendarRepository.GetPeriodByIdAsync(request.PeriodId)
?? throw new NotFoundException("Period not found");
var entry = new JournalEntry
{
JournalNo = await GenerateJournalNoAsync(request.EntryDate.Year),
PeriodId = request.PeriodId,
EntryDate = request.EntryDate,
JournalType = request.JournalType,
SourceModule = request.SourceModule,
Reference = request.Reference,
Narration = request.Narration,
PreparedBy = request.PreparedBy,
IsPosted = false,
IsReversed = false,
EntryStatus = JournalEntryStatus.Draft
};
List<JournalEntryLineRequest>? linesToBuild = null;
List<JournalEntryLine>? mirroredLines = null;
JournalEntry? original = null;
JournalCorrectionType correctionType = default;
var isCorrection = request.Correction != null;
var auditAction = AuditAction.Create;
if (isCorrection)
{
var correction = request.Correction!;
if (!Enum.TryParse(correction.CorrectionType, ignoreCase: true, out correctionType))
{
throw new BadRequestException(
$"Invalid correctionType '{correction.CorrectionType}'. Must be one of: {string.Join(", ", Enum.GetNames<JournalCorrectionType>())}.");
}
original = await _journalEntryRepository.GetJournalEntryByJournalNoAsync(correction.OriginalJournalNo)
?? throw new NotFoundException("Original journal entry not found");
if (original.EntryStatus != JournalEntryStatus.Posted)
{
throw new BadRequestException("Original journal entry must be posted before it can be corrected");
}
if (correctionType == JournalCorrectionType.FullReversal)
{
mirroredLines = [.. original.Lines.Select(l => new JournalEntryLine
{
AccountId = l.AccountId,
DebitAmount = l.CreditAmount,
CreditAmount = l.DebitAmount,
CurrencyCode = l.CurrencyCode,
ExchangeRate = l.ExchangeRate,
BaseDebit = l.BaseCredit,
BaseCredit = l.BaseDebit,
CostCenterId = l.CostCenterId,
Memo = l.Memo
})];
entry.ReversalOfId = original.JournalId;
auditAction = AuditAction.Reverse;
}
else
{
// PartialAdjustment: lines are now required inline, same as a plain create -
// there's no more follow-up call to supply them afterward.
linesToBuild = request.Lines;
auditAction = AuditAction.Adjust;
}
}
else
{
linesToBuild = request.Lines;
}
if (mirroredLines is null && (linesToBuild is null || linesToBuild.Count == 0))
{
throw new BadRequestException("lines is required");
}
await _unitOfWork.BeginAsync();
try
{
await _journalEntryRepository.AddJournalEntryAsync(entry);
await _unitOfWork.SaveChangesAsync();
List<JournalEntryLine> finalLines;
if (mirroredLines != null)
{
foreach (var line in mirroredLines)
{
line.JournalId = entry.JournalId;
}
await _journalEntryRepository.AddJournalEntryLinesAsync(mirroredLines);
await _unitOfWork.SaveChangesAsync();
finalLines = mirroredLines;
}
else
{
var builtLines = new List<JournalEntryLine>();
foreach (var lineRequest in linesToBuild!)
{
var line = await BuildLineAsync(lineRequest);
line.JournalId = entry.JournalId;
builtLines.Add(line);
}
await _journalEntryRepository.AddJournalEntryLinesAsync(builtLines);
await _unitOfWork.SaveChangesAsync();
finalLines = builtLines;
}
entry.Lines = finalLines;
if (isCorrection)
{
var correctionRow = new JournalCorrection
{
OriginalJournalId = original!.JournalId,
CorrectionJournalId = entry.JournalId,
CorrectionType = correctionType,
Reason = request.Correction!.Reason,
CorrectedBy = request.Correction!.CorrectedBy
};
await _journalEntryRepository.AddJournalCorrectionAsync(correctionRow);
await _unitOfWork.SaveChangesAsync();
}
await _auditLogRepository.AddAsync(new AuditLog
{
Category = AuditCategory.Ledger,
EntityName = "JournalEntry",
EntityId = entry.JournalId.ToString(),
Action = auditAction,
Description = $"Journal entry {entry.JournalNo} created",
PerformedBy = request.PreparedBy
});
await _unitOfWork.SaveChangesAsync();
// Immediately post - there is no client-facing DRAFT state to leave this in.
var totalDebit = finalLines.Sum(l => l.DebitAmount);
var totalCredit = finalLines.Sum(l => l.CreditAmount);
if (totalDebit != totalCredit)
{
throw new BadRequestException("Journal entry does not balance");
}
if (period.Status is PeriodStatus.Locked or PeriodStatus.Closed || period.LockGl)
{
throw new BadRequestException("Period is locked");
}
entry.IsPosted = true;
entry.EntryStatus = JournalEntryStatus.Posted;
if (correctionType == JournalCorrectionType.FullReversal)
{
original!.EntryStatus = JournalEntryStatus.Reversed;
original.IsReversed = true;
}
await _unitOfWork.SaveChangesAsync();
await _auditLogRepository.AddAsync(new AuditLog
{
Category = AuditCategory.Ledger,
EntityName = "JournalEntry",
EntityId = entry.JournalId.ToString(),
Action = AuditAction.Post,
Description = $"Journal entry {entry.JournalNo} posted",
PerformedBy = null
});
if (correctionType == JournalCorrectionType.FullReversal)
{
await _auditLogRepository.AddAsync(new AuditLog
{
Category = AuditCategory.Ledger,
EntityName = "JournalEntry",
EntityId = original!.JournalId.ToString(),
Action = AuditAction.Reverse,
Description = $"Journal entry {original.JournalNo} reversed by {entry.JournalNo}",
PerformedBy = null
});
}
await _unitOfWork.SaveChangesAsync();
await _unitOfWork.CommitAsync();
}
catch
{
await _unitOfWork.RollbackAsync();
throw;
}
return ResponseHelpers.Created("Journal entry created and posted successfully", entry);
}
public async Task<ApiResponse> GetByJournalNoAsync(string journalNo)
{
var entry = await _journalEntryRepository.GetJournalEntryByJournalNoAsync(journalNo)
?? throw new NotFoundException("Journal entry not found");
return ResponseHelpers.Ok("Journal entry retrieved successfully", entry);
}
private async Task<JournalEntryLine> BuildLineAsync(JournalEntryLineRequest request)
{
var account = await _accountRepository.GetByCodeAsync(request.AccountCode)
?? throw new NotFoundException($"Account {request.AccountCode} not found");
if (!account.IsPostable)
{
throw new BadRequestException($"Account {account.AccountCode} is not postable");
}
long? costCenterId = null;
if (!string.IsNullOrWhiteSpace(request.CostCenterCode))
{
var costCenter = await _costCenterRepository.GetByCodeAsync(request.CostCenterCode)
?? throw new NotFoundException($"Cost center {request.CostCenterCode} not found");
costCenterId = costCenter.CostCenterId;
}
return new JournalEntryLine
{
AccountId = account.AccountId,
DebitAmount = request.DebitAmount,
CreditAmount = request.CreditAmount,
CurrencyCode = account.CurrencyCode,
ExchangeRate = 1,
BaseDebit = request.DebitAmount,
BaseCredit = request.CreditAmount,
CostCenterId = costCenterId,
Memo = request.Memo
};
}
private async Task<string> GenerateJournalNoAsync(int year)
{
var count = await _journalEntryRepository.CountJournalEntriesForYearAsync(year);
return $"JV-{year}-{(count + 1):D6}";
}
}
}