Distribution_GL Initialization
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user