289 lines
12 KiB
C#
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}";
|
|
}
|
|
}
|
|
}
|