342 lines
15 KiB
C#
342 lines
15 KiB
C#
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;
|
|
}
|
|
}
|