Distribution_GL Initialization

This commit is contained in:
2026-10-20 13:07:07 +05:30
commit 3f16e0cb44
194 changed files with 35659 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
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<ApiResponse> CreateAsync(CreateFixedAssetRequest request)
{
if (!Enum.TryParse<DepreciationMethod>(request.DepreciationMethod, ignoreCase: true, out var depreciationMethod))
{
throw new BadRequestException(
$"Invalid depreciationMethod '{request.DepreciationMethod}'. Must be one of: {string.Join(", ", Enum.GetNames<DepreciationMethod>())}.");
}
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<string> 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<ApiResponse> CreateTransactionAsync(string assetCode, CreateFixedAssetTransactionRequest request)
{
var asset = await _fixedAssetRepository.GetByCodeAsync(assetCode)
?? throw new NotFoundException("Fixed asset not found");
if (!Enum.TryParse<FixedAssetTransactionType>(request.TransactionType, ignoreCase: true, out var transactionType))
{
throw new BadRequestException(
$"Invalid transactionType '{request.TransactionType}'. Must be one of: {string.Join(", ", Enum.GetNames<FixedAssetTransactionType>())}.");
}
return transactionType switch
{
FixedAssetTransactionType.Depreciate => await DepreciateAsync(asset, request),
FixedAssetTransactionType.Dispose => await DisposeAsync(asset, request),
_ => throw new BadRequestException($"Unsupported transactionType '{transactionType}'.")
};
}
public async Task<ApiResponse> ListAsync()
{
var assets = await _fixedAssetRepository.GetAllAsync();
return ResponseHelpers.Ok("Fixed assets retrieved successfully", assets);
}
private async Task<ApiResponse> 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<ApiResponse> 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<JournalEntryLineRequest>();
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<Account> 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<string> ResolveAccountCodeAsync(long accountId) =>
(await _accountRepository.GetByIdAsync(accountId))!.AccountCode;
}
}