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 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? linesToBuild = null; List? 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())}."); } 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 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(); 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 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 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 GenerateJournalNoAsync(int year) { var count = await _journalEntryRepository.CountJournalEntriesForYearAsync(year); return $"JV-{year}-{(count + 1):D6}"; } } }