575 lines
24 KiB
C#
575 lines
24 KiB
C#
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)
|
|
// └─ <this bank account's own name> (leaf, postable) - one per BankAccount
|
|
// Cash (root header, Asset, non-postable)
|
|
// └─ <cash account type name, e.g. "Petty Cash"> (node, non-postable) - one per CashAccountType
|
|
// └─ <this cash account's own name> (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<ApiResponse> 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<Account> 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<Account> 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<ApiResponse> 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<ApiResponse> ParseAndSaveAsync(BankAccount bankAccount, IXLWorksheet worksheet)
|
|
{
|
|
var imported = new List<BankStatementLine>();
|
|
var failedRows = new List<FailedRow>();
|
|
|
|
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<ApiResponse> 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<ApiResponse> 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<string> 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<ApiResponse> 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<CashAndBankAccountDto>();
|
|
|
|
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<ApiResponse> ListCashAccountTypesAsync()
|
|
{
|
|
var types = await _bankRepository.GetAllCashAccountTypesAsync();
|
|
return ResponseHelpers.Ok("Cash account types retrieved successfully", types);
|
|
}
|
|
|
|
public async Task<ApiResponse> 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<ApiResponse> 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<ApiResponse> 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;
|
|
}
|
|
}
|
|
}
|
|
}
|