using General_Ledger_Service.DTOs.Common; using General_Ledger_Service.DTOs.FixedAssets; 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 { public class FixedAssetService( IFixedAssetRepository fixedAssetRepository, IAccountRepository accountRepository, IFiscalCalendarRepository fiscalCalendarRepository, IJournalEntryService journalEntryService, IAuditLogRepository auditLogRepository, IUnitOfWork unitOfWork) : IFixedAssetService { private readonly IFixedAssetRepository _fixedAssetRepository = fixedAssetRepository; private readonly IAccountRepository _accountRepository = accountRepository; private readonly IFiscalCalendarRepository _fiscalCalendarRepository = fiscalCalendarRepository; private readonly IJournalEntryService _journalEntryService = journalEntryService; private readonly IAuditLogRepository _auditLogRepository = auditLogRepository; private readonly IUnitOfWork _unitOfWork = unitOfWork; public async Task CreateAsync(CreateFixedAssetRequest request) { if (!Enum.TryParse(request.DepreciationMethod, ignoreCase: true, out var depreciationMethod)) { throw new BadRequestException( $"Invalid depreciationMethod '{request.DepreciationMethod}'. Must be one of: {string.Join(", ", Enum.GetNames())}."); } if (depreciationMethod == DepreciationMethod.ReducingBalance && request.DepreciationRatePercent is null) { throw new BadRequestException("depreciationRatePercent is required when depreciationMethod is ReducingBalance"); } if (request.UsefulLifeMonths <= 0) { throw new BadRequestException("usefulLifeMonths must be greater than zero"); } if (request.ResidualValue > request.AcquisitionCost) { throw new BadRequestException("residualValue cannot exceed acquisitionCost"); } var assetAccount = await EnsurePostableAsync(request.AssetAccountCode, "Asset account"); var accumDeprAccount = await EnsurePostableAsync(request.AccumDeprAccountCode, "Accumulated depreciation account"); var deprExpenseAccount = await EnsurePostableAsync(request.DeprExpenseAccountCode, "Depreciation expense account"); var gainLossAccount = await EnsurePostableAsync(request.GainLossAccountCode, "Gain/loss account"); var assetCode = await GenerateAssetCodeAsync(); var asset = new FixedAsset { AssetCode = assetCode, AssetName = request.AssetName, AssetAccountId = assetAccount.AccountId, AccumDeprAccountId = accumDeprAccount.AccountId, DeprExpenseAccountId = deprExpenseAccount.AccountId, GainLossAccountId = gainLossAccount.AccountId, AcquisitionDate = request.AcquisitionDate, AcquisitionCost = request.AcquisitionCost, ResidualValue = request.ResidualValue, UsefulLifeMonths = request.UsefulLifeMonths, DepreciationMethod = depreciationMethod, DepreciationRatePercent = request.DepreciationRatePercent, Status = FixedAssetStatus.Active }; await _unitOfWork.BeginAsync(); try { await _fixedAssetRepository.AddAsync(asset); await _unitOfWork.SaveChangesAsync(); await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Ledger, EntityName = "FixedAsset", EntityId = asset.AssetId.ToString(), Action = AuditAction.Create, Description = $"Fixed asset {asset.AssetCode} registered", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); } catch { await _unitOfWork.RollbackAsync(); throw; } return ResponseHelpers.Created("Fixed asset registered successfully", asset); } private async Task GenerateAssetCodeAsync() { // Probes candidates directly rather than trusting a total count (same fix as // AccountService.GenerateAccountCodeAsync) so a pre-existing, manually-coded asset // can never collide with a generated one. for (var seq = 1; seq <= 999999; seq++) { var code = $"FA-{seq:D6}"; if (await _fixedAssetRepository.GetByCodeAsync(code) is null) { return code; } } throw new BadRequestException("Maximum number of fixed assets has been reached."); } public async Task CreateTransactionAsync(string assetCode, CreateFixedAssetTransactionRequest request) { var asset = await _fixedAssetRepository.GetByCodeAsync(assetCode) ?? throw new NotFoundException("Fixed asset not found"); if (!Enum.TryParse(request.TransactionType, ignoreCase: true, out var transactionType)) { throw new BadRequestException( $"Invalid transactionType '{request.TransactionType}'. Must be one of: {string.Join(", ", Enum.GetNames())}."); } return transactionType switch { FixedAssetTransactionType.Depreciate => await DepreciateAsync(asset, request), FixedAssetTransactionType.Dispose => await DisposeAsync(asset, request), _ => throw new BadRequestException($"Unsupported transactionType '{transactionType}'.") }; } public async Task ListAsync() { var assets = await _fixedAssetRepository.GetAllAsync(); return ResponseHelpers.Ok("Fixed assets retrieved successfully", assets); } private async Task DepreciateAsync(FixedAsset asset, CreateFixedAssetTransactionRequest request) { if (asset.Status == FixedAssetStatus.Disposed) { throw new BadRequestException("Asset has been disposed"); } if (asset.Status == FixedAssetStatus.FullyDepreciated) { throw new BadRequestException("Asset is already fully depreciated"); } var periodId = request.PeriodId ?? throw new BadRequestException("periodId is required for a Depreciate transaction"); var period = await _fiscalCalendarRepository.GetPeriodByIdAsync(periodId) ?? throw new NotFoundException("Period not found"); if (period.Status is PeriodStatus.Locked or PeriodStatus.Closed || period.LockGl) { throw new BadRequestException("Period is locked"); } var depreciableBase = asset.AcquisitionCost - asset.ResidualValue; var previousSchedule = await _fixedAssetRepository.GetLatestScheduleByAssetIdAsync(asset.AssetId); var priorAccumulated = previousSchedule?.AccumulatedDepreciation ?? 0m; var amount = asset.DepreciationMethod switch { Enums.DepreciationMethod.StraightLine => depreciableBase / asset.UsefulLifeMonths, Enums.DepreciationMethod.ReducingBalance => (asset.AcquisitionCost - priorAccumulated) * (asset.DepreciationRatePercent!.Value / 100), _ => throw new BadRequestException($"Unsupported depreciationMethod '{asset.DepreciationMethod}'.") }; var remaining = depreciableBase - priorAccumulated; if (amount > remaining) { amount = remaining; } if (amount <= 0) { throw new BadRequestException("Asset is already fully depreciated"); } var newAccumulated = priorAccumulated + amount; var newNetBookValue = asset.AcquisitionCost - newAccumulated; var journalRequest = new CreateJournalEntryRequest { PeriodId = periodId, EntryDate = period.EndDate, JournalType = "DEPRECIATION", Reference = asset.AssetCode, Narration = $"Depreciation for {asset.AssetCode} - period {periodId}", Lines = [ new JournalEntryLineRequest { AccountCode = await ResolveAccountCodeAsync(asset.DeprExpenseAccountId), DebitAmount = amount, CreditAmount = 0 }, new JournalEntryLineRequest { AccountCode = await ResolveAccountCodeAsync(asset.AccumDeprAccountId), DebitAmount = 0, CreditAmount = amount } ] }; var createResponse = await _journalEntryService.CreateAsync(journalRequest); var postedEntry = (JournalEntry)createResponse.Data!; if (newAccumulated >= depreciableBase) { asset.Status = FixedAssetStatus.FullyDepreciated; } var schedule = new DepreciationSchedule { AssetId = asset.AssetId, PeriodId = periodId, DepreciationAmount = amount, AccumulatedDepreciation = newAccumulated, NetBookValue = newNetBookValue, JournalId = postedEntry.JournalId }; await _unitOfWork.BeginAsync(); try { await _fixedAssetRepository.AddScheduleAsync(schedule); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); } catch { await _unitOfWork.RollbackAsync(); throw; } return ResponseHelpers.Created("Depreciation posted successfully", schedule); } private async Task DisposeAsync(FixedAsset asset, CreateFixedAssetTransactionRequest request) { if (asset.Status == FixedAssetStatus.Disposed) { throw new BadRequestException("Asset has already been disposed"); } var disposalDate = request.DisposalDate ?? throw new BadRequestException("disposalDate is required for a Dispose transaction"); var proceeds = request.Proceeds ?? throw new BadRequestException("proceeds is required for a Dispose transaction"); var proceedsAccountCode = request.ProceedsAccountCode ?? throw new BadRequestException("proceedsAccountCode is required for a Dispose transaction"); var proceedsAccount = await EnsurePostableAsync(proceedsAccountCode, "Proceeds account"); var period = await _fiscalCalendarRepository.GetPeriodByDateAsync(disposalDate) ?? throw new NotFoundException("No accounting period covers the disposal date"); var latestSchedule = await _fixedAssetRepository.GetLatestScheduleByAssetIdAsync(asset.AssetId); var accumulatedDepreciation = latestSchedule?.AccumulatedDepreciation ?? 0m; var netBookValue = asset.AcquisitionCost - accumulatedDepreciation; var gainLoss = proceeds - netBookValue; var lines = new List(); if (proceeds > 0) { lines.Add(new JournalEntryLineRequest { AccountCode = proceedsAccount.AccountCode, DebitAmount = proceeds, CreditAmount = 0 }); } if (accumulatedDepreciation > 0) { lines.Add(new JournalEntryLineRequest { AccountCode = await ResolveAccountCodeAsync(asset.AccumDeprAccountId), DebitAmount = accumulatedDepreciation, CreditAmount = 0 }); } lines.Add(new JournalEntryLineRequest { AccountCode = await ResolveAccountCodeAsync(asset.AssetAccountId), DebitAmount = 0, CreditAmount = asset.AcquisitionCost }); if (gainLoss > 0) { lines.Add(new JournalEntryLineRequest { AccountCode = await ResolveAccountCodeAsync(asset.GainLossAccountId), DebitAmount = 0, CreditAmount = gainLoss }); } else if (gainLoss < 0) { lines.Add(new JournalEntryLineRequest { AccountCode = await ResolveAccountCodeAsync(asset.GainLossAccountId), DebitAmount = -gainLoss, CreditAmount = 0 }); } var journalRequest = new CreateJournalEntryRequest { PeriodId = period.PeriodId, EntryDate = disposalDate, JournalType = "ASSET_DISPOSAL", Reference = asset.AssetCode, Narration = $"Disposal of {asset.AssetCode}", Lines = lines }; var createResponse = await _journalEntryService.CreateAsync(journalRequest); var postedEntry = (JournalEntry)createResponse.Data!; asset.Status = FixedAssetStatus.Disposed; var disposal = new AssetDisposal { AssetId = asset.AssetId, DisposalDate = disposalDate, Proceeds = proceeds, NetBookValueAtDisposal = netBookValue, GainLoss = gainLoss, JournalId = postedEntry.JournalId }; await _unitOfWork.BeginAsync(); try { await _fixedAssetRepository.AddDisposalAsync(disposal); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); } catch { await _unitOfWork.RollbackAsync(); throw; } return ResponseHelpers.Created("Asset disposal posted successfully", disposal); } private async Task EnsurePostableAsync(string accountCode, string label) { var account = await _accountRepository.GetByCodeAsync(accountCode) ?? throw new NotFoundException($"{label} not found"); if (!account.IsPostable) { throw new BadRequestException($"{label} ({account.AccountCode}) is not postable"); } return account; } private async Task ResolveAccountCodeAsync(long accountId) => (await _accountRepository.GetByIdAsync(accountId))!.AccountCode; } }