using System.Text.Json; using General_Ledger_Service.DTOs.Accounts; using General_Ledger_Service.DTOs.Common; using General_Ledger_Service.Enums; using General_Ledger_Service.Exceptions; using General_Ledger_Service.Helpers; 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 AccountService( IAccountRepository accountRepository, ISystemConfigRepository systemConfigRepository, IAuditLogRepository auditLogRepository, IUnitOfWork unitOfWork) : IAccountService { private readonly IAccountRepository _accountRepository = accountRepository; private readonly ISystemConfigRepository _systemConfigRepository = systemConfigRepository; private readonly IAuditLogRepository _auditLogRepository = auditLogRepository; private readonly IUnitOfWork _unitOfWork = unitOfWork; public async Task CreateAccountAsync(CreateAccountRequest request) { Account? parent = null; if (request.ParentAccountId.HasValue) { parent = await _accountRepository.GetByIdAsync(request.ParentAccountId.Value) ?? throw new NotFoundException("Parent account not found"); var resultingDepth = await _accountRepository.GetAncestorDepthAsync(parent.AccountId); await DepthGuard.EnsureWithinMaxDepthAsync( _systemConfigRepository, "COA_MAX_DEPTH", "COA_ENFORCE_MAX_DEPTH", resultingDepth, "Maximum Chart of Accounts depth exceeded"); } var accountCode = await AccountCodeGenerator.GenerateAsync(_accountRepository, request.AccountTypeId, parent); var account = new Account { AccountCode = accountCode, AccountName = request.AccountName, ParentAccountId = request.ParentAccountId, AccountTypeId = request.AccountTypeId, IsControlAccount = request.IsControlAccount, IsPostable = request.IsPostable, CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? "LKR" : request.CurrencyCode, IsActive = true }; await _unitOfWork.BeginAsync(); try { await _accountRepository.AddAsync(account); await _unitOfWork.SaveChangesAsync(); await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Ledger, EntityName = "Account", EntityId = account.AccountId.ToString(), Action = AuditAction.Create, Description = $"Account {account.AccountCode} created", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); } catch { await _unitOfWork.RollbackAsync(); throw; } return ResponseHelpers.Created("Account created successfully", account); } public async Task GetAccountByCodeAsync(string accountCode, string? expand) { var account = await _accountRepository.GetByCodeAsync(accountCode) ?? throw new NotFoundException("Account not found"); var breadcrumb = await _accountRepository.GetAncestorBreadcrumbAsync(account.AccountId); List? descendantTree = null; if (string.Equals(expand, "tree", StringComparison.OrdinalIgnoreCase)) { descendantTree = await _accountRepository.GetDescendantTreeAsync(account.AccountId); } var data = new AccountDetailResponse { Account = account, AncestorBreadcrumb = breadcrumb, DescendantTree = descendantTree }; return ResponseHelpers.Ok("Account retrieved successfully", data); } public async Task UpdateAccountAsync(string accountCode, Dictionary payload) { var request = new UpdateAccountRequest { AccountName = DataExtractor.GetOptionalString(payload, "accountName"), AccountTypeId = (short?)DataExtractor.GetNullableInt(payload, "accountTypeId"), IsControlAccount = GetOptionalBool(payload, "isControlAccount"), IsPostable = GetOptionalBool(payload, "isPostable"), CurrencyCode = DataExtractor.GetOptionalString(payload, "currencyCode"), CashFlowCategory = DataExtractor.GetOptionalString(payload, "cashFlowCategory"), IncomeStatementSection = DataExtractor.GetOptionalString(payload, "incomeStatementSection"), BalanceSheetClassification = DataExtractor.GetOptionalString(payload, "balanceSheetClassification"), IsTaxDeductible = GetOptionalBool(payload, "isTaxDeductible"), IsActive = GetOptionalBool(payload, "isActive"), ParentAccountIdProvided = payload.ContainsKey("parentAccountId"), ParentAccountId = payload.TryGetValue("parentAccountId", out var parentElement) && parentElement.ValueKind != JsonValueKind.Null ? parentElement.GetInt64() : null }; var account = await _accountRepository.GetByCodeAsync(accountCode) ?? throw new NotFoundException("Account not found"); account.AccountName = request.AccountName ?? account.AccountName; account.AccountTypeId = request.AccountTypeId ?? account.AccountTypeId; account.IsControlAccount = request.IsControlAccount ?? account.IsControlAccount; account.IsPostable = request.IsPostable ?? account.IsPostable; account.CurrencyCode = request.CurrencyCode ?? account.CurrencyCode; account.CashFlowCategory = request.CashFlowCategory ?? account.CashFlowCategory; account.IncomeStatementSection = request.IncomeStatementSection ?? account.IncomeStatementSection; account.BalanceSheetClassification = request.BalanceSheetClassification ?? account.BalanceSheetClassification; account.IsTaxDeductible = request.IsTaxDeductible ?? account.IsTaxDeductible; account.IsActive = request.IsActive ?? account.IsActive; var parentChanged = false; if (request.ParentAccountIdProvided && request.ParentAccountId != account.ParentAccountId) { parentChanged = true; var newParentId = request.ParentAccountId; if (newParentId.HasValue) { if (newParentId.Value == account.AccountId) { throw new BadRequestException("An account cannot be its own parent"); } _ = await _accountRepository.GetByIdAsync(newParentId.Value) ?? throw new NotFoundException("Parent account not found"); var wouldCycle = await _accountRepository.WouldCreateCycleAsync(account.AccountId, newParentId.Value); if (wouldCycle) { throw new BadRequestException("Cannot re-parent: the new parent is a descendant of this account"); } var resultingDepth = await _accountRepository.GetAncestorDepthAsync(newParentId.Value); await DepthGuard.EnsureWithinMaxDepthAsync( _systemConfigRepository, "COA_MAX_DEPTH", "COA_ENFORCE_MAX_DEPTH", resultingDepth, "Maximum Chart of Accounts depth exceeded"); } account.ParentAccountId = newParentId; } await _unitOfWork.BeginAsync(); try { await _auditLogRepository.AddAsync(new AuditLog { Category = parentChanged ? AuditCategory.Ledger : AuditCategory.Administrative, EntityName = "Account", EntityId = account.AccountId.ToString(), Action = AuditAction.Update, Description = $"Account {account.AccountCode} updated", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); } catch { await _unitOfWork.RollbackAsync(); throw; } return ResponseHelpers.Ok("Account updated successfully", account); } public async Task ListAccountsAsync(ListAccountsRequest request) { // No default page/pageSize - paging is only applied when the caller explicitly // supplies both (RepositoryHelpers.ApplyPaging); a non-positive value is treated as // "not supplied" rather than silently substituted with one. var page = request.Page is > 0 ? request.Page : null; var pageSize = request.PageSize is > 0 ? request.PageSize : null; var paged = page.HasValue && pageSize.HasValue; var (items, totalCount) = await _accountRepository.SearchAsync( request.AccountTypeId, request.IsActive, request.IsPostable, page, pageSize); var data = new AccountListResponse { Items = items, TotalCount = totalCount, Page = paged ? page : null, PageSize = paged ? pageSize : null }; return ResponseHelpers.Ok("Accounts retrieved successfully", data); } private static bool? GetOptionalBool(Dictionary data, string key) { if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null) { return null; } if (element.ValueKind == JsonValueKind.True) return true; if (element.ValueKind == JsonValueKind.False) return false; throw new BadRequestException($"Field '{key}' must be a valid boolean."); } } }