using ClosedXML.Excel; using General_Ledger_Service.DTOs.Bank; using General_Ledger_Service.DTOs.Common; 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 BankService( IBankRepository bankRepository, IAccountRepository accountRepository, IJournalEntryRepository journalEntryRepository, IAuditLogRepository auditLogRepository, IUnitOfWork unitOfWork) : IBankService { private readonly IBankRepository _bankRepository = bankRepository; private readonly IAccountRepository _accountRepository = accountRepository; private readonly IJournalEntryRepository _journalEntryRepository = journalEntryRepository; private readonly IAuditLogRepository _auditLogRepository = auditLogRepository; private readonly IUnitOfWork _unitOfWork = unitOfWork; // Retrofit 2026-07-31 - the caller no longer selects an existing GL account for a new // bank/cash account; this service auto-creates the whole backing GL structure instead: // Bank (root header, Asset, non-postable) // └─ (leaf, postable) - one per BankAccount // Cash (root header, Asset, non-postable) // └─ (node, non-postable) - one per CashAccountType // └─ (leaf, postable) - one per CashAccount // "Bank"/"Cash" and each type node are found-or-created by fixed name (GetOrCreateSystemRootAsync / // CashAccountType.GlAccountId), matching the user's explicit naming choice ("Bank"/"Cash", // not "Bank Accounts"/"Cash Accounts"). Depth here is fixed and shallow (2-3 levels, // well under COA_MAX_DEPTH's default of 6), so DepthGuard isn't invoked - it exists to // guard against unbounded user-driven nesting, not this fixed system structure. public async Task CreateBankAccountAsync(CreateBankAccountRequest request) { var currencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? "LKR" : request.CurrencyCode; await _unitOfWork.BeginAsync(); try { var bankRoot = await GetOrCreateSystemRootAsync("Bank"); var glAccount = await CreateSystemChildAccountAsync(bankRoot, request.AccountName, isPostable: true, currencyCode); var bankAccount = new BankAccount { AccountName = request.AccountName, BankName = request.BankName, AccountNumber = request.AccountNumber, GlAccountId = glAccount.AccountId, CurrencyCode = currencyCode }; await _bankRepository.AddAsync(bankAccount); await _unitOfWork.SaveChangesAsync(); await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Administrative, EntityName = "BankAccount", EntityId = bankAccount.BankAccountId.ToString(), Action = AuditAction.Create, Description = $"Bank account {bankAccount.AccountName} created", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); return ResponseHelpers.Created("Bank account created successfully", bankAccount); } catch { await _unitOfWork.RollbackAsync(); throw; } } // Finds an existing root Asset account with this exact name, or creates it - "Bank"/"Cash" // are system-owned singleton headers, always looked up by fixed name (never user-supplied // free text), so a collision would only ever be this same method racing itself. private async Task GetOrCreateSystemRootAsync(string name, short accountTypeId = 1) { var existing = await _accountRepository.GetByNameAndParentAsync(name, null); if (existing != null) { return existing; } var code = await AccountCodeGenerator.GenerateAsync(_accountRepository, accountTypeId, null); var account = new Account { AccountCode = code, AccountName = name, ParentAccountId = null, AccountTypeId = accountTypeId, IsControlAccount = true, IsPostable = false, CurrencyCode = "LKR", IsActive = true }; 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} ({account.AccountName}) auto-created as a system GL header", PerformedBy = null }); return account; } // Always creates a new child account - used both for a type's own (non-postable) node // under "Cash" and for the (postable) leaf account backing one specific bank/cash account, // which is always a fresh 1:1 creation, never reused. private async Task CreateSystemChildAccountAsync(Account parent, string name, bool isPostable, string currencyCode) { var code = await AccountCodeGenerator.GenerateAsync(_accountRepository, parent.AccountTypeId, parent); var account = new Account { AccountCode = code, AccountName = name, ParentAccountId = parent.AccountId, AccountTypeId = parent.AccountTypeId, IsControlAccount = !isPostable, IsPostable = isPostable, CurrencyCode = currencyCode, IsActive = true }; 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} ({account.AccountName}) auto-created as a system GL {(isPostable ? "account" : "header")}", PerformedBy = null }); return account; } public async Task ImportStatementLinesAsync(long bankAccountId, Stream fileStream) { var bankAccount = await _bankRepository.GetByIdAsync(bankAccountId) ?? throw new NotFoundException("Bank account not found"); XLWorkbook workbook; IXLWorksheet worksheet; try { workbook = new XLWorkbook(fileStream); worksheet = workbook.Worksheets.First(); } catch (Exception) { throw new BadRequestException("Uploaded file is not a valid .xlsx spreadsheet"); } using (workbook) { return await ParseAndSaveAsync(bankAccount, worksheet); } } private async Task ParseAndSaveAsync(BankAccount bankAccount, IXLWorksheet worksheet) { var imported = new List(); var failedRows = new List(); foreach (var row in worksheet.RowsUsed().Skip(1)) { var rowNumber = row.RowNumber(); var txnDateCell = row.Cell(1); var descriptionCell = row.Cell(2); var amountCell = row.Cell(3); if (!txnDateCell.TryGetValue(out DateTime txnDate)) { failedRows.Add(new FailedRow { RowNumber = rowNumber, Reason = "txnDate is missing or not a valid date" }); continue; } if (!amountCell.TryGetValue(out decimal amount)) { failedRows.Add(new FailedRow { RowNumber = rowNumber, Reason = "amount is missing or not a valid number" }); continue; } imported.Add(new BankStatementLine { BankAccountId = bankAccount.BankAccountId, TxnDate = DateOnly.FromDateTime(txnDate), Description = descriptionCell.IsEmpty() ? null : descriptionCell.GetString(), Amount = amount, IsReconciled = false }); } await _unitOfWork.BeginAsync(); try { if (imported.Count > 0) { await _bankRepository.AddStatementLinesAsync(imported); await _unitOfWork.SaveChangesAsync(); } await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Administrative, EntityName = "BankAccount", EntityId = bankAccount.BankAccountId.ToString(), Action = AuditAction.Create, Description = $"{imported.Count} statement line(s) imported, {failedRows.Count} row(s) failed", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); } catch { await _unitOfWork.RollbackAsync(); throw; } return ResponseHelpers.Created("Bank statement import completed", new ImportStatementLinesResponse { Imported = imported, FailedRows = failedRows }); } public async Task ReconcileStatementLineAsync(long stmtLineId, ReconcileStatementLineRequest request) { var statementLine = await _bankRepository.GetStatementLineByIdAsync(stmtLineId) ?? throw new NotFoundException("Bank statement line not found"); if (statementLine.IsReconciled) { throw new BadRequestException("Statement line is already reconciled"); } var journalEntry = await _journalEntryRepository.GetJournalEntryByJournalNoAsync(request.MatchedJournalNo) ?? throw new NotFoundException("Journal entry not found"); if (!journalEntry.IsPosted) { throw new BadRequestException("Journal entry must be posted before it can be used for reconciliation"); } var bankAccount = await _bankRepository.GetByIdAsync(statementLine.BankAccountId) ?? throw new NotFoundException("Bank account not found"); var netMovement = journalEntry.Lines .Where(l => l.AccountId == bankAccount.GlAccountId) .Sum(l => l.DebitAmount - l.CreditAmount); if (netMovement != statementLine.Amount) { throw new BadRequestException( $"Statement line amount ({statementLine.Amount}) does not match the journal entry's movement on the bank's GL account ({netMovement})"); } statementLine.IsReconciled = true; statementLine.MatchedJournalId = journalEntry.JournalId; await _unitOfWork.BeginAsync(); try { await _unitOfWork.SaveChangesAsync(); await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Administrative, EntityName = "BankStatementLine", EntityId = statementLine.StmtLineId.ToString(), Action = AuditAction.Update, Description = $"Statement line {statementLine.StmtLineId} reconciled against journal entry {journalEntry.JournalNo}", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); } catch { await _unitOfWork.RollbackAsync(); throw; } return ResponseHelpers.Ok("Statement line reconciled successfully", statementLine); } public async Task CreateCashAccountAsync(CreateCashAccountRequest request) { if (string.IsNullOrWhiteSpace(request.CashAccountTypeName)) { throw new BadRequestException("cashAccountTypeName is required"); } var currencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? "LKR" : request.CurrencyCode; var cashAccountType = await _bankRepository.GetCashAccountTypeByNameAsync(request.CashAccountTypeName.Trim()); var accountNumber = string.IsNullOrWhiteSpace(request.AccountNumber) ? await GenerateCashAccountNumberAsync() : request.AccountNumber.Trim(); await _unitOfWork.BeginAsync(); try { // "Other" flow: a cashAccountTypeName that doesn't match an existing row creates // one on the fly, so it appears in the type list for future selections. if (cashAccountType is null) { cashAccountType = await _bankRepository.AddCashAccountTypeAsync(new CashAccountType { Name = request.CashAccountTypeName.Trim() }); await _unitOfWork.SaveChangesAsync(); } var cashRoot = await GetOrCreateSystemRootAsync("Cash"); Account typeGlAccount; if (cashAccountType.GlAccountId is null) { // First time this type has ever backed a cash account - create its GL node // under "Cash" and persist the link on the type row itself, so every future // cash account of this type reuses the same node instead of creating a new one. typeGlAccount = await CreateSystemChildAccountAsync(cashRoot, cashAccountType.Name, isPostable: false, currencyCode: "LKR"); cashAccountType.GlAccountId = typeGlAccount.AccountId; await _unitOfWork.SaveChangesAsync(); } else { typeGlAccount = await _accountRepository.GetByIdAsync(cashAccountType.GlAccountId.Value) ?? throw new NotFoundException("Cash account type's GL header not found"); } var glAccount = await CreateSystemChildAccountAsync(typeGlAccount, request.AccountName, isPostable: true, currencyCode); var cashAccount = new CashAccount { AccountName = request.AccountName, AccountNumber = accountNumber, CashAccountTypeId = cashAccountType.CashAccountTypeId, GlAccountId = glAccount.AccountId, CurrencyCode = currencyCode }; await _bankRepository.AddCashAccountAsync(cashAccount); await _unitOfWork.SaveChangesAsync(); await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Administrative, EntityName = "CashAccount", EntityId = cashAccount.CashAccountId.ToString(), Action = AuditAction.Create, Description = $"Cash account {cashAccount.AccountName} created", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); return ResponseHelpers.Created("Cash account created successfully", cashAccount); } catch { await _unitOfWork.RollbackAsync(); throw; } } private async Task GenerateCashAccountNumberAsync() { for (var seq = 1; seq <= 1000; seq++) { var candidate = $"CASH-{seq:D6}"; if (!await _bankRepository.CashAccountNumberExistsAsync(candidate)) { return candidate; } } throw new BadRequestException("Unable to auto-generate a unique cash account number - supply one explicitly"); } public async Task ListCashAndBankAccountsAsync(string? accountType) { var filter = string.IsNullOrWhiteSpace(accountType) ? "Both" : accountType.Trim(); if (!string.Equals(filter, "Cash", StringComparison.OrdinalIgnoreCase) && !string.Equals(filter, "Bank", StringComparison.OrdinalIgnoreCase) && !string.Equals(filter, "Both", StringComparison.OrdinalIgnoreCase)) { throw new BadRequestException($"Invalid accountType '{accountType}'. Must be one of: Cash, Bank, Both."); } var combined = new List(); if (!string.Equals(filter, "Cash", StringComparison.OrdinalIgnoreCase)) { var bankAccounts = await _bankRepository.GetAllAsync(); combined.AddRange(bankAccounts.Select(b => new CashAndBankAccountDto { AccountType = "Bank", AccountId = b.BankAccountId, AccountName = b.AccountName, BankName = b.BankName, AccountNumber = b.AccountNumber, GlAccountId = b.GlAccountId, GlAccountCode = b.GlAccount.AccountCode, CurrencyCode = b.CurrencyCode, CreatedAt = b.CreatedAt })); } if (!string.Equals(filter, "Bank", StringComparison.OrdinalIgnoreCase)) { var cashAccounts = await _bankRepository.GetAllCashAccountsAsync(); combined.AddRange(cashAccounts.Select(c => new CashAndBankAccountDto { AccountType = "Cash", AccountId = c.CashAccountId, AccountName = c.AccountName, CashAccountTypeName = c.CashAccountType.Name, AccountNumber = c.AccountNumber, GlAccountId = c.GlAccountId, GlAccountCode = c.GlAccount.AccountCode, CurrencyCode = c.CurrencyCode, CreatedAt = c.CreatedAt, IsActive = c.IsActive, IsDefault = c.IsDefault })); } return ResponseHelpers.Ok("Cash and bank accounts retrieved successfully", combined.OrderBy(a => a.AccountName).ToList()); } public async Task ListCashAccountTypesAsync() { var types = await _bankRepository.GetAllCashAccountTypesAsync(); return ResponseHelpers.Ok("Cash account types retrieved successfully", types); } public async Task UpdateCashAccountAsync(long cashAccountId, UpdateCashAccountRequest request) { if (string.IsNullOrWhiteSpace(request.AccountName)) { throw new BadRequestException("accountName is required"); } var cashAccount = await _bankRepository.GetCashAccountByIdAsync(cashAccountId) ?? throw new NotFoundException("Cash account not found"); await _unitOfWork.BeginAsync(); try { cashAccount.AccountName = request.AccountName.Trim(); await _unitOfWork.SaveChangesAsync(); await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Administrative, EntityName = "CashAccount", EntityId = cashAccount.CashAccountId.ToString(), Action = AuditAction.Update, Description = $"Cash account renamed to {cashAccount.AccountName}", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); return ResponseHelpers.Ok("Cash account updated successfully", cashAccount); } catch { await _unitOfWork.RollbackAsync(); throw; } } public async Task SetCashAccountStatusAsync(long cashAccountId, SetCashAccountStatusRequest request) { var cashAccount = await _bankRepository.GetCashAccountByIdAsync(cashAccountId) ?? throw new NotFoundException("Cash account not found"); if (!request.IsActive && cashAccount.IsDefault) { throw new BadRequestException($"'{cashAccount.AccountName}' is the default cash account and cannot be deactivated."); } await _unitOfWork.BeginAsync(); try { cashAccount.IsActive = request.IsActive; await _unitOfWork.SaveChangesAsync(); await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Administrative, EntityName = "CashAccount", EntityId = cashAccount.CashAccountId.ToString(), Action = AuditAction.Update, Description = $"Cash account {cashAccount.AccountName} {(request.IsActive ? "activated" : "deactivated")}", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); return ResponseHelpers.Ok("Cash account status updated successfully", cashAccount); } catch { await _unitOfWork.RollbackAsync(); throw; } } public async Task DeleteCashAccountAsync(long cashAccountId) { var cashAccount = await _bankRepository.GetCashAccountByIdAsync(cashAccountId) ?? throw new NotFoundException("Cash account not found"); if (cashAccount.IsDefault) { throw new BadRequestException($"'{cashAccount.AccountName}' is the default cash account and cannot be deleted."); } await _unitOfWork.BeginAsync(); try { _bankRepository.RemoveCashAccount(cashAccount); await _unitOfWork.SaveChangesAsync(); await _auditLogRepository.AddAsync(new AuditLog { Category = AuditCategory.Administrative, EntityName = "CashAccount", EntityId = cashAccount.CashAccountId.ToString(), Action = AuditAction.Delete, Description = $"Cash account {cashAccount.AccountName} deleted", PerformedBy = null }); await _unitOfWork.SaveChangesAsync(); await _unitOfWork.CommitAsync(); return ResponseHelpers.Ok("Cash account deleted successfully", null); } catch { await _unitOfWork.RollbackAsync(); throw; } } } }