Distribution_GL Initialization
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
using General_Ledger_Service.Exceptions;
|
||||
using General_Ledger_Service.Interfaces.Repositories;
|
||||
using General_Ledger_Service.Models;
|
||||
|
||||
namespace General_Ledger_Service.Services
|
||||
{
|
||||
// Extracted from AccountService (2026-07-31) so BankService's auto-created "Bank"/"Cash" GL
|
||||
// hierarchy (see BankService.CreateBankAccountAsync/CreateCashAccountAsync) can reuse the same
|
||||
// Rule 8.1 code-generation scheme instead of duplicating it - same shared-static-helper shape
|
||||
// as RecursiveTreeQueries/DepthGuard (CLAUDE.md Rule 11), not a base class.
|
||||
public static class AccountCodeGenerator
|
||||
{
|
||||
private static readonly Dictionary<short, int> AccountTypeBlockStart = new()
|
||||
{
|
||||
[1] = 1000, // Asset
|
||||
[2] = 2000, // Liability
|
||||
[3] = 3000, // Equity
|
||||
[4] = 4000, // Income
|
||||
[5] = 5000 // Expense
|
||||
};
|
||||
|
||||
public static async Task<string> GenerateAsync(IAccountRepository accountRepository, short accountTypeId, Account? parent)
|
||||
{
|
||||
if (parent != null)
|
||||
{
|
||||
for (var suffix = 1; suffix <= 99; suffix++)
|
||||
{
|
||||
var code = $"{parent.AccountCode}-{suffix:D2}";
|
||||
|
||||
if (code.Length > 20)
|
||||
{
|
||||
throw new BadRequestException("Cannot generate an account code: nesting is too deep for the 20-character account code column.");
|
||||
}
|
||||
|
||||
if (await accountRepository.GetByCodeAsync(code) is null)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException("Maximum number of child accounts under this parent has been reached.");
|
||||
}
|
||||
|
||||
if (!AccountTypeBlockStart.TryGetValue(accountTypeId, out var blockStart))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid accountTypeId '{accountTypeId}'. Must be one of: 1 (Asset), 2 (Liability), 3 (Equity), 4 (Income), 5 (Expense).");
|
||||
}
|
||||
|
||||
// Probes each of the 10 designed slots directly rather than trusting a root count,
|
||||
// since pre-existing accounts created before this generation scheme existed (manually
|
||||
// coded, e.g. "5100") don't necessarily occupy slots in count order - a count-based
|
||||
// guess can collide with one of those and crash on the DB's unique constraint instead
|
||||
// of failing cleanly.
|
||||
for (var slot = 0; slot < 10; slot++)
|
||||
{
|
||||
var code = (blockStart + slot * 100).ToString();
|
||||
if (await accountRepository.GetByCodeAsync(code) is null)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException("Maximum number of root-level accounts for this account type has been reached.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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<ApiResponse> 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<ApiResponse> 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<AccountTreeNode>? 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<ApiResponse> UpdateAccountAsync(string accountCode, Dictionary<string, JsonElement> 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<ApiResponse> 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<string, JsonElement> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using General_Ledger_Service.DTOs.AuditLog;
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.Enums;
|
||||
using General_Ledger_Service.Interfaces.Repositories;
|
||||
using General_Ledger_Service.Interfaces.Services;
|
||||
|
||||
namespace General_Ledger_Service.Services
|
||||
{
|
||||
public class AuditLogService(IAuditLogRepository auditLogRepository) : IAuditLogService
|
||||
{
|
||||
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
|
||||
|
||||
public async Task<ApiResponse> ListAsync(ListAuditLogRequest 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;
|
||||
|
||||
List<AuditCategory> categories;
|
||||
if (request.Category is null || request.Category.Count == 0)
|
||||
{
|
||||
categories = [AuditCategory.Ledger, AuditCategory.Administrative];
|
||||
}
|
||||
else
|
||||
{
|
||||
categories = [.. request.Category
|
||||
.Select(c => Enum.TryParse<AuditCategory>(c, ignoreCase: true, out var parsed) ? (AuditCategory?)parsed : null)
|
||||
.Where(c => c.HasValue)
|
||||
.Select(c => c!.Value)
|
||||
.Distinct()];
|
||||
|
||||
// A typo'd category matches nothing, rather than falling back to "match everything".
|
||||
if (categories.Count == 0)
|
||||
{
|
||||
return EmptyResponse(paged ? page : null, paged ? pageSize : null);
|
||||
}
|
||||
}
|
||||
|
||||
AuditAction? action = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.Action))
|
||||
{
|
||||
if (!Enum.TryParse<AuditAction>(request.Action, ignoreCase: true, out var parsedAction))
|
||||
{
|
||||
return EmptyResponse(paged ? page : null, paged ? pageSize : null);
|
||||
}
|
||||
action = parsedAction;
|
||||
}
|
||||
|
||||
var (items, totalCount) = await _auditLogRepository.ListAsync(
|
||||
categories, request.EntityName, action, request.PerformedBy, request.From, request.To, page, pageSize);
|
||||
|
||||
return ResponseHelpers.Ok("Audit log retrieved successfully",
|
||||
new AuditLogListResponse { Items = items, TotalCount = totalCount, Page = paged ? page : null, PageSize = paged ? pageSize : null });
|
||||
}
|
||||
|
||||
private static ApiResponse EmptyResponse(int? page, int? pageSize) =>
|
||||
ResponseHelpers.Ok("Audit log retrieved successfully",
|
||||
new AuditLogListResponse { Items = [], TotalCount = 0, Page = page, PageSize = pageSize });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using General_Ledger_Service.DTOs.Budget;
|
||||
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 BudgetService(
|
||||
IBudgetRepository budgetRepository,
|
||||
IFiscalCalendarRepository fiscalCalendarRepository,
|
||||
IAccountRepository accountRepository,
|
||||
ICostCenterRepository costCenterRepository,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
IUnitOfWork unitOfWork) : IBudgetService
|
||||
{
|
||||
private readonly IBudgetRepository _budgetRepository = budgetRepository;
|
||||
private readonly IFiscalCalendarRepository _fiscalCalendarRepository = fiscalCalendarRepository;
|
||||
private readonly IAccountRepository _accountRepository = accountRepository;
|
||||
private readonly ICostCenterRepository _costCenterRepository = costCenterRepository;
|
||||
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork;
|
||||
|
||||
public async Task<ApiResponse> CreateAsync(CreateBudgetRequest request)
|
||||
{
|
||||
_ = await _fiscalCalendarRepository.GetFiscalYearByIdAsync(request.FiscalYearId)
|
||||
?? throw new NotFoundException("Fiscal year not found");
|
||||
|
||||
var budget = new Budget
|
||||
{
|
||||
FiscalYearId = request.FiscalYearId,
|
||||
Name = request.Name
|
||||
};
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _budgetRepository.AddAsync(budget);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "Budget",
|
||||
EntityId = budget.BudgetId.ToString(),
|
||||
Action = AuditAction.Create,
|
||||
Description = $"Budget {budget.Name} created",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Budget created successfully", budget);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> AddLinesAsync(long budgetId, List<BudgetLineRequest> request)
|
||||
{
|
||||
var budget = await _budgetRepository.GetByIdAsync(budgetId)
|
||||
?? throw new NotFoundException("Budget not found");
|
||||
|
||||
var lines = new List<BudgetLine>();
|
||||
foreach (var lineRequest in request)
|
||||
{
|
||||
var account = await _accountRepository.GetByCodeAsync(lineRequest.AccountCode)
|
||||
?? throw new NotFoundException($"Account {lineRequest.AccountCode} not found");
|
||||
|
||||
_ = await _fiscalCalendarRepository.GetPeriodByIdAsync(lineRequest.PeriodId)
|
||||
?? throw new NotFoundException($"Period {lineRequest.PeriodId} not found");
|
||||
|
||||
long? costCenterId = null;
|
||||
if (!string.IsNullOrWhiteSpace(lineRequest.CostCenterCode))
|
||||
{
|
||||
var costCenter = await _costCenterRepository.GetByCodeAsync(lineRequest.CostCenterCode)
|
||||
?? throw new NotFoundException($"Cost center {lineRequest.CostCenterCode} not found");
|
||||
costCenterId = costCenter.CostCenterId;
|
||||
}
|
||||
|
||||
lines.Add(new BudgetLine
|
||||
{
|
||||
BudgetId = budgetId,
|
||||
AccountId = account.AccountId,
|
||||
PeriodId = lineRequest.PeriodId,
|
||||
CostCenterId = costCenterId,
|
||||
BudgetedAmount = lineRequest.BudgetedAmount
|
||||
});
|
||||
}
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _budgetRepository.AddLinesAsync(lines);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "Budget",
|
||||
EntityId = budget.BudgetId.ToString(),
|
||||
Action = AuditAction.Update,
|
||||
Description = $"{lines.Count} budget line(s) added to {budget.Name}",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Budget lines added successfully", lines);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> ListAsync()
|
||||
{
|
||||
var budgets = await _budgetRepository.GetAllAsync();
|
||||
return ResponseHelpers.Ok("Budgets retrieved successfully", budgets);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
using System.Text.Json;
|
||||
using General_Ledger_Service.DTOs.Cheques;
|
||||
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 ChequeService(
|
||||
IChequeRepository chequeRepository,
|
||||
IBankRepository bankRepository,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
IUnitOfWork unitOfWork) : IChequeService
|
||||
{
|
||||
private readonly IChequeRepository _chequeRepository = chequeRepository;
|
||||
private readonly IBankRepository _bankRepository = bankRepository;
|
||||
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork;
|
||||
|
||||
public async Task<ApiResponse> CreateChequeBookAsync(CreateChequeBookRequest request)
|
||||
{
|
||||
_ = await _bankRepository.GetByIdAsync(request.BankAccountId)
|
||||
?? throw new NotFoundException("Bank account not found");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ChequeBookNo))
|
||||
{
|
||||
throw new BadRequestException("chequeBookNo is required");
|
||||
}
|
||||
|
||||
if (await _chequeRepository.ChequeBookNoExistsAsync(request.ChequeBookNo))
|
||||
{
|
||||
throw new BadRequestException($"Cheque book number '{request.ChequeBookNo}' already exists");
|
||||
}
|
||||
|
||||
if (request.TotalLeaves <= 0)
|
||||
{
|
||||
throw new BadRequestException("totalLeaves must be greater than 0");
|
||||
}
|
||||
|
||||
var chequeNumbers = BuildChequeNoRange(request.StartChequeNo, request.EndChequeNo, request.TotalLeaves);
|
||||
|
||||
var chequeBook = new ChequeBook
|
||||
{
|
||||
BranchId = request.BranchId,
|
||||
BankAccountId = request.BankAccountId,
|
||||
ChequeBookNo = request.ChequeBookNo,
|
||||
StartChequeNo = request.StartChequeNo,
|
||||
EndChequeNo = request.EndChequeNo,
|
||||
TotalLeaves = request.TotalLeaves,
|
||||
ReceivedDate = request.ReceivedDate,
|
||||
Status = ChequeBookStatus.Active,
|
||||
Description = request.Description,
|
||||
IsActive = true,
|
||||
CreatedBy = request.CreatedBy
|
||||
};
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _chequeRepository.AddChequeBookAsync(chequeBook);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
var pages = chequeNumbers.Select(chequeNo => new ChequePage
|
||||
{
|
||||
ChequeBookId = chequeBook.ChequeBookId,
|
||||
ChequeNo = chequeNo,
|
||||
IssueStatus = ChequeIssueStatus.Unused,
|
||||
CreatedBy = request.CreatedBy
|
||||
});
|
||||
|
||||
await _chequeRepository.AddChequePagesAsync(pages);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "ChequeBook",
|
||||
EntityId = chequeBook.ChequeBookId.ToString(),
|
||||
Action = AuditAction.Create,
|
||||
Description = $"Cheque book {chequeBook.ChequeBookNo} created with {chequeBook.TotalLeaves} leaves",
|
||||
PerformedBy = request.CreatedBy
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Cheque book created successfully", chequeBook);
|
||||
}
|
||||
|
||||
// Cheque numbers are printed sequentially on the physical book, so the range is generated
|
||||
// by numeric increment, preserving the digit-width (leading zeros) of StartChequeNo.
|
||||
private static List<string> BuildChequeNoRange(string startChequeNo, string endChequeNo, int totalLeaves)
|
||||
{
|
||||
if (!long.TryParse(startChequeNo, out var start) || !long.TryParse(endChequeNo, out var end))
|
||||
{
|
||||
throw new BadRequestException("startChequeNo and endChequeNo must be numeric");
|
||||
}
|
||||
|
||||
if (end < start)
|
||||
{
|
||||
throw new BadRequestException("endChequeNo must not be less than startChequeNo");
|
||||
}
|
||||
|
||||
var count = end - start + 1;
|
||||
if (count != totalLeaves)
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"totalLeaves ({totalLeaves}) does not match the range startChequeNo-endChequeNo ({count} cheques)");
|
||||
}
|
||||
|
||||
var width = startChequeNo.Length;
|
||||
var result = new List<string>();
|
||||
for (var n = start; n <= end; n++)
|
||||
{
|
||||
result.Add(n.ToString().PadLeft(width, '0'));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> GetChequeBookByNoAsync(string chequeBookNo, string? expand)
|
||||
{
|
||||
var includePages = string.Equals(expand, "pages", StringComparison.OrdinalIgnoreCase);
|
||||
var chequeBook = await _chequeRepository.GetChequeBookByNoAsync(chequeBookNo, includePages)
|
||||
?? throw new NotFoundException("Cheque book not found");
|
||||
|
||||
return ResponseHelpers.Ok("Cheque book retrieved successfully", chequeBook);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> ListChequeBooksAsync(ListChequeBooksRequest request)
|
||||
{
|
||||
var page = request.Page is > 0 ? request.Page : null;
|
||||
var pageSize = request.PageSize is > 0 ? request.PageSize : null;
|
||||
|
||||
var (items, totalCount) = await _chequeRepository.ListChequeBooksAsync(
|
||||
request.BankAccountId, request.BranchId, request.Status, page, pageSize);
|
||||
|
||||
return ResponseHelpers.Ok("Cheque books retrieved successfully",
|
||||
new ChequeBookListResponse { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> IssueChequePageAsync(string chequeNo, IssueChequePageRequest request)
|
||||
{
|
||||
var page = await _chequeRepository.GetChequePageByNoAsync(chequeNo)
|
||||
?? throw new NotFoundException("Cheque page not found");
|
||||
|
||||
if (page.IssueStatus != ChequeIssueStatus.Unused)
|
||||
{
|
||||
throw new BadRequestException($"Cheque page must be Unused to be issued (currently {page.IssueStatus})");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PayeeName))
|
||||
{
|
||||
throw new BadRequestException("payeeName is required");
|
||||
}
|
||||
|
||||
if (request.Amount <= 0)
|
||||
{
|
||||
throw new BadRequestException("amount must be greater than 0");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ChequePayeeType>(request.PayeeType, ignoreCase: true, out var payeeType))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid payeeType '{request.PayeeType}'. Must be one of: {string.Join(", ", Enum.GetNames<ChequePayeeType>())}.");
|
||||
}
|
||||
|
||||
page.PayeeType = payeeType;
|
||||
page.PayeeId = request.PayeeId;
|
||||
page.PayeeName = request.PayeeName;
|
||||
page.IssueDate = request.IssueDate;
|
||||
page.Amount = request.Amount;
|
||||
page.CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? "LKR" : request.CurrencyCode;
|
||||
page.VoucherId = request.VoucherId;
|
||||
page.ReferenceNo = request.ReferenceNo;
|
||||
page.Purpose = request.Purpose;
|
||||
page.IsCrossCheque = request.IsCrossCheque;
|
||||
page.IsAccountPayee = request.IsAccountPayee;
|
||||
page.IsPostDated = request.IsPostDated;
|
||||
page.Notes = request.Notes;
|
||||
page.PrintedBy = request.PrintedBy;
|
||||
page.PrintedAt = DateTime.UtcNow;
|
||||
page.IssueStatus = ChequeIssueStatus.Issued;
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "ChequePage",
|
||||
EntityId = page.ChequePageId.ToString(),
|
||||
Action = AuditAction.Update,
|
||||
Description = $"Cheque {page.ChequeNo} issued to {page.PayeeName} for {page.Amount}",
|
||||
PerformedBy = request.PrintedBy
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Ok("Cheque page issued successfully", page);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> UpdateChequePageStatusAsync(string chequeNo, Dictionary<string, JsonElement> payload)
|
||||
{
|
||||
var rawAction = DataExtractor.GetRequiredString(payload, "action");
|
||||
if (!Enum.TryParse<ChequePageAction>(rawAction, ignoreCase: true, out var action))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid action '{rawAction}'. Must be one of: {string.Join(", ", Enum.GetNames<ChequePageAction>())}.");
|
||||
}
|
||||
|
||||
var page = await _chequeRepository.GetChequePageByNoAsync(chequeNo)
|
||||
?? throw new NotFoundException("Cheque page not found");
|
||||
|
||||
string description;
|
||||
switch (action)
|
||||
{
|
||||
case ChequePageAction.Clear:
|
||||
if (page.IssueStatus != ChequeIssueStatus.Issued)
|
||||
{
|
||||
throw new BadRequestException($"Cheque must be Issued to be cleared (currently {page.IssueStatus})");
|
||||
}
|
||||
page.ClearedDate = DataExtractor.GetRequiredDateOnly(payload, "clearedDate");
|
||||
page.ClearedByBank = true;
|
||||
page.IssueStatus = ChequeIssueStatus.Cleared;
|
||||
description = $"Cheque {page.ChequeNo} cleared";
|
||||
break;
|
||||
|
||||
case ChequePageAction.Bounce:
|
||||
if (page.IssueStatus != ChequeIssueStatus.Issued)
|
||||
{
|
||||
throw new BadRequestException($"Cheque must be Issued to be bounced (currently {page.IssueStatus})");
|
||||
}
|
||||
page.IssueStatus = ChequeIssueStatus.Bounced;
|
||||
description = $"Cheque {page.ChequeNo} bounced";
|
||||
break;
|
||||
|
||||
case ChequePageAction.Cancel:
|
||||
if (page.IssueStatus != ChequeIssueStatus.Unused && page.IssueStatus != ChequeIssueStatus.Issued)
|
||||
{
|
||||
throw new BadRequestException($"Cheque cannot be cancelled from status {page.IssueStatus}");
|
||||
}
|
||||
page.CancelReason = DataExtractor.GetRequiredString(payload, "cancelReason");
|
||||
page.IssueStatus = ChequeIssueStatus.Cancelled;
|
||||
description = $"Cheque {page.ChequeNo} cancelled";
|
||||
break;
|
||||
|
||||
case ChequePageAction.Void:
|
||||
if (page.IssueStatus != ChequeIssueStatus.Unused)
|
||||
{
|
||||
throw new BadRequestException($"Only an Unused cheque can be voided (currently {page.IssueStatus})");
|
||||
}
|
||||
page.IssueStatus = ChequeIssueStatus.Void;
|
||||
description = $"Cheque {page.ChequeNo} voided";
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BadRequestException($"Invalid action '{rawAction}'.");
|
||||
}
|
||||
|
||||
var performedBy = DataExtractor.GetOptionalString(payload, "performedBy");
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "ChequePage",
|
||||
EntityId = page.ChequePageId.ToString(),
|
||||
Action = AuditAction.Update,
|
||||
Description = description,
|
||||
PerformedBy = performedBy
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Ok("Cheque page status updated successfully", page);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> GetChequePageByNoAsync(string chequeNo)
|
||||
{
|
||||
var page = await _chequeRepository.GetChequePageByNoAsync(chequeNo)
|
||||
?? throw new NotFoundException("Cheque page not found");
|
||||
|
||||
return ResponseHelpers.Ok("Cheque page retrieved successfully", page);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> ListChequePagesAsync(ListChequePagesRequest request)
|
||||
{
|
||||
var page = request.Page is > 0 ? request.Page : null;
|
||||
var pageSize = request.PageSize is > 0 ? request.PageSize : null;
|
||||
|
||||
var (items, totalCount) = await _chequeRepository.ListChequePagesAsync(
|
||||
request.ChequeBookNo, request.IssueStatus, request.PayeeType, request.PayeeId, page, pageSize);
|
||||
|
||||
return ResponseHelpers.Ok("Cheque pages retrieved successfully",
|
||||
new ChequePageListResponse { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> CreateReceivedChequeAsync(CreateReceivedChequeRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ChequeNo))
|
||||
{
|
||||
throw new BadRequestException("chequeNo is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ReceivedFromName))
|
||||
{
|
||||
throw new BadRequestException("receivedFromName is required");
|
||||
}
|
||||
|
||||
if (request.Amount <= 0)
|
||||
{
|
||||
throw new BadRequestException("amount must be greater than 0");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ReceivedChequeFromType>(request.ReceivedFromType, ignoreCase: true, out var receivedFromType))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid receivedFromType '{request.ReceivedFromType}'. Must be one of: {string.Join(", ", Enum.GetNames<ReceivedChequeFromType>())}.");
|
||||
}
|
||||
|
||||
var receivedCheque = new ReceivedCheque
|
||||
{
|
||||
CompanyId = request.CompanyId,
|
||||
BranchId = request.BranchId,
|
||||
ReceivedFromType = receivedFromType,
|
||||
ReceivedFromId = request.ReceivedFromId,
|
||||
ReceivedFromName = request.ReceivedFromName,
|
||||
DrawerBankName = request.DrawerBankName,
|
||||
DrawerBankBranch = request.DrawerBankBranch,
|
||||
AccountHolderName = request.AccountHolderName,
|
||||
ChequeNo = request.ChequeNo,
|
||||
ChequeDate = request.ChequeDate,
|
||||
Amount = request.Amount,
|
||||
ReceivedDate = request.ReceivedDate,
|
||||
Status = ReceivedChequeStatus.Received,
|
||||
ReferenceType = request.ReferenceType,
|
||||
ReferenceId = request.ReferenceId,
|
||||
Notes = request.Notes,
|
||||
CreatedBy = request.CreatedBy
|
||||
};
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _chequeRepository.AddReceivedChequeAsync(receivedCheque);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "ReceivedCheque",
|
||||
EntityId = receivedCheque.ReceivedChequeId.ToString(),
|
||||
Action = AuditAction.Create,
|
||||
Description = $"Received cheque {receivedCheque.ChequeNo} from {receivedCheque.ReceivedFromName}",
|
||||
PerformedBy = request.CreatedBy
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Received cheque recorded successfully", receivedCheque);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> UpdateReceivedChequeStatusAsync(long receivedChequeId, Dictionary<string, JsonElement> payload)
|
||||
{
|
||||
var rawAction = DataExtractor.GetRequiredString(payload, "action");
|
||||
if (!Enum.TryParse<ReceivedChequeAction>(rawAction, ignoreCase: true, out var action))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid action '{rawAction}'. Must be one of: {string.Join(", ", Enum.GetNames<ReceivedChequeAction>())}.");
|
||||
}
|
||||
|
||||
var receivedCheque = await _chequeRepository.GetReceivedChequeByIdAsync(receivedChequeId)
|
||||
?? throw new NotFoundException("Received cheque not found");
|
||||
|
||||
string description;
|
||||
switch (action)
|
||||
{
|
||||
case ReceivedChequeAction.Deposit:
|
||||
if (receivedCheque.Status != ReceivedChequeStatus.Received)
|
||||
{
|
||||
throw new BadRequestException($"Received cheque must be in Received status to be deposited (currently {receivedCheque.Status})");
|
||||
}
|
||||
|
||||
var depositBankAccountId = payload.TryGetValue("depositBankAccountId", out var depositElement) && depositElement.ValueKind != JsonValueKind.Null
|
||||
? depositElement.GetInt64()
|
||||
: throw new BadRequestException("Missing required field 'depositBankAccountId'.");
|
||||
|
||||
_ = await _bankRepository.GetByIdAsync(depositBankAccountId)
|
||||
?? throw new NotFoundException("Deposit bank account not found");
|
||||
|
||||
receivedCheque.DepositBankAccountId = depositBankAccountId;
|
||||
receivedCheque.DepositDate = DataExtractor.GetRequiredDateOnly(payload, "depositDate");
|
||||
receivedCheque.Status = ReceivedChequeStatus.Deposited;
|
||||
description = $"Received cheque {receivedCheque.ChequeNo} deposited";
|
||||
break;
|
||||
|
||||
case ReceivedChequeAction.Clear:
|
||||
if (receivedCheque.Status != ReceivedChequeStatus.Deposited)
|
||||
{
|
||||
throw new BadRequestException($"Received cheque must be Deposited to be cleared (currently {receivedCheque.Status})");
|
||||
}
|
||||
receivedCheque.Status = ReceivedChequeStatus.Cleared;
|
||||
description = $"Received cheque {receivedCheque.ChequeNo} cleared";
|
||||
break;
|
||||
|
||||
case ReceivedChequeAction.Return:
|
||||
if (receivedCheque.Status != ReceivedChequeStatus.Deposited)
|
||||
{
|
||||
throw new BadRequestException($"Received cheque must be Deposited to be returned (currently {receivedCheque.Status})");
|
||||
}
|
||||
receivedCheque.Status = ReceivedChequeStatus.Returned;
|
||||
description = $"Received cheque {receivedCheque.ChequeNo} returned";
|
||||
break;
|
||||
|
||||
case ReceivedChequeAction.Cancel:
|
||||
if (receivedCheque.Status != ReceivedChequeStatus.Received)
|
||||
{
|
||||
throw new BadRequestException($"Received cheque can only be cancelled from Received status (currently {receivedCheque.Status})");
|
||||
}
|
||||
receivedCheque.Status = ReceivedChequeStatus.Cancelled;
|
||||
description = $"Received cheque {receivedCheque.ChequeNo} cancelled";
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BadRequestException($"Invalid action '{rawAction}'.");
|
||||
}
|
||||
|
||||
var notes = DataExtractor.GetOptionalString(payload, "notes");
|
||||
if (notes is not null)
|
||||
{
|
||||
receivedCheque.Notes = notes;
|
||||
}
|
||||
|
||||
var performedBy = DataExtractor.GetOptionalString(payload, "performedBy");
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "ReceivedCheque",
|
||||
EntityId = receivedCheque.ReceivedChequeId.ToString(),
|
||||
Action = AuditAction.Update,
|
||||
Description = description,
|
||||
PerformedBy = performedBy
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Ok("Received cheque status updated successfully", receivedCheque);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> GetReceivedChequeByIdAsync(long receivedChequeId)
|
||||
{
|
||||
var receivedCheque = await _chequeRepository.GetReceivedChequeByIdAsync(receivedChequeId)
|
||||
?? throw new NotFoundException("Received cheque not found");
|
||||
|
||||
return ResponseHelpers.Ok("Received cheque retrieved successfully", receivedCheque);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> ListReceivedChequesAsync(ListReceivedChequesRequest request)
|
||||
{
|
||||
var page = request.Page is > 0 ? request.Page : null;
|
||||
var pageSize = request.PageSize is > 0 ? request.PageSize : null;
|
||||
|
||||
var (items, totalCount) = await _chequeRepository.ListReceivedChequesAsync(
|
||||
request.CompanyId, request.BranchId, request.Status, request.ReceivedFromType, page, pageSize);
|
||||
|
||||
return ResponseHelpers.Ok("Received cheques retrieved successfully",
|
||||
new ReceivedChequeListResponse { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.DTOs.CostCenters;
|
||||
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 CostCenterService(
|
||||
ICostCenterRepository costCenterRepository,
|
||||
ISystemConfigRepository systemConfigRepository,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
IUnitOfWork unitOfWork) : ICostCenterService
|
||||
{
|
||||
private readonly ICostCenterRepository _costCenterRepository = costCenterRepository;
|
||||
private readonly ISystemConfigRepository _systemConfigRepository = systemConfigRepository;
|
||||
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork;
|
||||
|
||||
public async Task<ApiResponse> CreateCostCenterAsync(CreateCostCenterRequest request)
|
||||
{
|
||||
CostCenter? parent = null;
|
||||
|
||||
if (request.ParentCostCenterId.HasValue)
|
||||
{
|
||||
parent = await _costCenterRepository.GetByIdAsync(request.ParentCostCenterId.Value)
|
||||
?? throw new NotFoundException("Parent cost center not found");
|
||||
|
||||
var resultingDepth = await _costCenterRepository.GetAncestorDepthAsync(parent.CostCenterId);
|
||||
await DepthGuard.EnsureWithinMaxDepthAsync(
|
||||
_systemConfigRepository, "COST_CENTER_MAX_DEPTH", null, resultingDepth, "Maximum Cost Center depth exceeded");
|
||||
}
|
||||
|
||||
var code = await GenerateCostCenterCodeAsync(parent);
|
||||
|
||||
var costCenter = new CostCenter
|
||||
{
|
||||
Code = code,
|
||||
Name = request.Name,
|
||||
ParentCostCenterId = request.ParentCostCenterId,
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _costCenterRepository.AddAsync(costCenter);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "CostCenter",
|
||||
EntityId = costCenter.CostCenterId.ToString(),
|
||||
Action = AuditAction.Create,
|
||||
Description = $"Cost center {costCenter.Code} created",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Cost center created successfully", costCenter);
|
||||
}
|
||||
|
||||
private async Task<string> GenerateCostCenterCodeAsync(CostCenter? parent)
|
||||
{
|
||||
if (parent != null)
|
||||
{
|
||||
for (var suffix = 1; suffix <= 99; suffix++)
|
||||
{
|
||||
var code = $"{parent.Code}-{suffix:D2}";
|
||||
if (await _costCenterRepository.GetByCodeAsync(code) is null)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException("Maximum number of child cost centers under this parent has been reached.");
|
||||
}
|
||||
|
||||
// Probes each candidate slot directly (same fix as AccountService.GenerateAccountCodeAsync)
|
||||
// rather than trusting a root count, so a pre-existing, manually-coded cost center can
|
||||
// never collide with a generated one.
|
||||
for (var slot = 1; slot <= 999; slot++)
|
||||
{
|
||||
var code = $"CC-{slot:D3}";
|
||||
if (await _costCenterRepository.GetByCodeAsync(code) is null)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException("Maximum number of root-level cost centers has been reached.");
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> GetCostCenterByCodeAsync(string costCenterCode, string? expand)
|
||||
{
|
||||
var costCenter = await _costCenterRepository.GetByCodeAsync(costCenterCode)
|
||||
?? throw new NotFoundException("Cost center not found");
|
||||
|
||||
var breadcrumb = await _costCenterRepository.GetAncestorBreadcrumbAsync(costCenter.CostCenterId);
|
||||
|
||||
List<CostCenterTreeNode>? descendantTree = null;
|
||||
if (string.Equals(expand, "tree", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descendantTree = await _costCenterRepository.GetDescendantTreeAsync(costCenter.CostCenterId);
|
||||
}
|
||||
|
||||
var data = new CostCenterDetailResponse
|
||||
{
|
||||
CostCenter = costCenter,
|
||||
AncestorBreadcrumb = breadcrumb,
|
||||
DescendantTree = descendantTree
|
||||
};
|
||||
|
||||
return ResponseHelpers.Ok("Cost center retrieved successfully", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
|
||||
namespace General_Ledger_Service.Services.CsvDocuments
|
||||
{
|
||||
// Retrofit 2026-07-31 - Section column carries the classified section name (Non-Current
|
||||
// Assets, Current Assets, Equity, Non-Current/Current Liabilities, Unclassified) rather than
|
||||
// the old AccountType/depth-indentation pair (05_LKAS_Report_PDF_Templates.md Section 10).
|
||||
public static class BalanceSheetCsvDocument
|
||||
{
|
||||
public static byte[] Generate(BalanceSheetReportDto data)
|
||||
{
|
||||
var csv = new CsvBuilder();
|
||||
csv.AddRow("Section", "Account Code", "Account Name", "Balance");
|
||||
|
||||
void WriteSection(string section, BalanceSheetSectionDto s)
|
||||
{
|
||||
foreach (var line in s.Lines)
|
||||
{
|
||||
csv.AddRow(section, line.AccountCode, line.AccountName, line.Balance);
|
||||
}
|
||||
}
|
||||
|
||||
WriteSection("Non-Current Assets", data.NonCurrentAssets);
|
||||
WriteSection("Current Assets", data.CurrentAssets);
|
||||
WriteSection("Unclassified Assets", data.UnclassifiedAssets);
|
||||
csv.AddRow("Asset", "", "TOTAL ASSETS", data.TotalAssets);
|
||||
|
||||
WriteSection("Equity", data.Equity);
|
||||
WriteSection("Non-Current Liabilities", data.NonCurrentLiabilities);
|
||||
WriteSection("Current Liabilities", data.CurrentLiabilities);
|
||||
WriteSection("Unclassified Liabilities", data.UnclassifiedLiabilities);
|
||||
csv.AddRow("Liability/Equity", "", "TOTAL EQUITY AND LIABILITIES", data.TotalEquityAndLiabilities);
|
||||
|
||||
return csv.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
|
||||
namespace General_Ledger_Service.Services.CsvDocuments
|
||||
{
|
||||
public static class BudgetVsActualCsvDocument
|
||||
{
|
||||
public static byte[] Generate(List<BudgetVsActualRowDto> rows)
|
||||
{
|
||||
var csv = new CsvBuilder();
|
||||
csv.AddRow("Account Code", "Account Name", "Period Id", "Budgeted", "Actual", "Variance");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
csv.AddRow(row.AccountCode, row.AccountName, row.PeriodId, row.BudgetedAmount, row.ActualAmount, row.Variance);
|
||||
}
|
||||
|
||||
csv.AddRow("", "TOTAL", "", rows.Sum(r => r.BudgetedAmount), rows.Sum(r => r.ActualAmount), rows.Sum(r => r.Variance));
|
||||
return csv.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
|
||||
namespace General_Ledger_Service.Services.CsvDocuments
|
||||
{
|
||||
// Same simplification as Services/PdfDocuments/CashFlowPdfDocument - no printed opening/closing
|
||||
// balance row (still in the outputFormat=Json response for the reconciliation check); non-cash
|
||||
// adjustments and working-capital changes combined and re-bucketed by sign.
|
||||
public static class CashFlowCsvDocument
|
||||
{
|
||||
public static byte[] Generate(CashFlowReportDto data)
|
||||
{
|
||||
var operating = data.OperatingActivities;
|
||||
var operatingProfitBeforeWorkingCapital = operating.ProfitForPeriod + operating.NonCashAdjustments.Sum(a => a.Amount);
|
||||
|
||||
var movements = operating.NonCashAdjustments
|
||||
.Select(a => (Description: a.Description, Amount: a.Amount))
|
||||
.Concat(operating.WorkingCapitalChanges.Select(c => (Description: $"{c.Direction} in {c.AccountName}", Amount: c.ChangeAmount)))
|
||||
.ToList();
|
||||
|
||||
var csv = new CsvBuilder();
|
||||
csv.AddRow("Category", "Description", "Amount");
|
||||
|
||||
csv.AddRow("Operating", "Profit for the period", operating.ProfitForPeriod);
|
||||
csv.AddRow("Operating", "Operating profit before working capital changes", operatingProfitBeforeWorkingCapital);
|
||||
|
||||
foreach (var line in movements.Where(m => m.Amount > 0))
|
||||
{
|
||||
csv.AddRow("Operating - Additions to Cash", line.Description, line.Amount);
|
||||
}
|
||||
|
||||
foreach (var line in movements.Where(m => m.Amount <= 0))
|
||||
{
|
||||
csv.AddRow("Operating - Subtractions From Cash", line.Description, line.Amount);
|
||||
}
|
||||
|
||||
csv.AddRow("Operating", "Net Cash from Operating Activities", operating.NetCashFromOperatingActivities);
|
||||
|
||||
foreach (var line in data.InvestingActivities.Lines)
|
||||
{
|
||||
csv.AddRow("Investing", line.AccountName, line.Amount);
|
||||
}
|
||||
csv.AddRow("Investing", "Net Cash from/(used in) Investing Activities", data.InvestingActivities.NetCashFromInvestingActivities);
|
||||
|
||||
foreach (var line in data.FinancingActivities.Lines)
|
||||
{
|
||||
csv.AddRow("Financing", line.AccountName, line.Amount);
|
||||
}
|
||||
csv.AddRow("Financing", "Net Cash from/(used in) Financing Activities", data.FinancingActivities.NetCashFromFinancingActivities);
|
||||
|
||||
csv.AddRow("", "Net Increase/(Decrease) in Cash and Cash Equivalents", data.NetIncreaseDecreaseInCash);
|
||||
return csv.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace General_Ledger_Service.Services.CsvDocuments
|
||||
{
|
||||
// Shared row-building/escaping helper for every report's CSV writer (05_LKAS_Report_PDF_Templates.md
|
||||
// Section 10) - parallel to Services/PdfDocuments/ReportPageHeader.cs, one small shared piece
|
||||
// reused by seven otherwise-independent writer classes, not a base class (CLAUDE.md Rule 2).
|
||||
public class CsvBuilder
|
||||
{
|
||||
private readonly StringBuilder _sb = new();
|
||||
|
||||
public CsvBuilder AddRow(params object?[] values)
|
||||
{
|
||||
_sb.AppendLine(string.Join(",", values.Select(Escape)));
|
||||
return this;
|
||||
}
|
||||
|
||||
public byte[] Build()
|
||||
{
|
||||
// UTF-8 BOM so Excel (the realistic consumer of a downloaded .csv) recognizes the
|
||||
// encoding instead of guessing at a locale-default codepage.
|
||||
var preamble = Encoding.UTF8.GetPreamble();
|
||||
var body = Encoding.UTF8.GetBytes(_sb.ToString());
|
||||
var result = new byte[preamble.Length + body.Length];
|
||||
preamble.CopyTo(result, 0);
|
||||
body.CopyTo(result, preamble.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Escape(object? value)
|
||||
{
|
||||
var text = value switch
|
||||
{
|
||||
null => "",
|
||||
decimal d => d.ToString(CultureInfo.InvariantCulture),
|
||||
DateOnly d => d.ToString("yyyy-MM-dd"),
|
||||
_ => value.ToString() ?? ""
|
||||
};
|
||||
|
||||
return text.Contains(',') || text.Contains('"') || text.Contains('\n')
|
||||
? $"\"{text.Replace("\"", "\"\"")}\""
|
||||
: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
|
||||
namespace General_Ledger_Service.Services.CsvDocuments
|
||||
{
|
||||
public static class GeneralLedgerCsvDocument
|
||||
{
|
||||
public static byte[] Generate(List<GeneralLedgerLineDto> rows)
|
||||
{
|
||||
var csv = new CsvBuilder();
|
||||
csv.AddRow("Account Code", "Account Name", "Date", "Particulars", "J.R. (Journal No.)", "Debit", "Credit", "Balance");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
csv.AddRow(
|
||||
row.AccountCode, row.AccountName, row.EntryDate, row.Narration, row.JournalNo,
|
||||
row.DebitAmount == 0 ? null : row.DebitAmount,
|
||||
row.CreditAmount == 0 ? null : row.CreditAmount,
|
||||
row.RunningBalance);
|
||||
}
|
||||
|
||||
return csv.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
|
||||
namespace General_Ledger_Service.Services.CsvDocuments
|
||||
{
|
||||
public static class IncomeTaxComputationCsvDocument
|
||||
{
|
||||
public static byte[] Generate(IncomeTaxComputationDto data)
|
||||
{
|
||||
var csv = new CsvBuilder();
|
||||
csv.AddRow("Line", "Amount");
|
||||
|
||||
csv.AddRow("Profit Before Tax", data.ProfitBeforeTax);
|
||||
csv.AddRow("Add: Non-Deductible Expenses", data.NonDeductibleExpenses);
|
||||
csv.AddRow("Less: Allowable Deductions", -data.AllowableDeductions);
|
||||
csv.AddRow("Adjusted Business Profit", data.AdjustedBusinessProfit);
|
||||
csv.AddRow("Add: Other Taxable Income", data.OtherTaxableIncome);
|
||||
csv.AddRow("Assessable Income", data.AssessableIncome);
|
||||
csv.AddRow("Less: Qualifying Payments / Reliefs", -data.QualifyingPaymentsReliefs);
|
||||
csv.AddRow("Taxable Income", data.TaxableIncome);
|
||||
csv.AddRow($"Corporate Income Tax @ {data.TaxRatePercent:0.00}%", data.CorporateIncomeTax);
|
||||
csv.AddRow("Add: Surcharge / Education Levy", data.SurchargeAmount);
|
||||
csv.AddRow("Gross Tax Liability", data.GrossTaxLiability);
|
||||
csv.AddRow("Less: APIT Credit", -data.ApitCredit);
|
||||
csv.AddRow("Less: WHT Credit", -data.WhtCredit);
|
||||
csv.AddRow("Less: Quarterly Tax Payments", -data.QuarterlyTaxPayments);
|
||||
csv.AddRow(data.BalanceTaxPayable < 0 ? "BALANCE TAX REFUNDABLE" : "BALANCE TAX PAYABLE", data.BalanceTaxPayable);
|
||||
|
||||
return csv.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
|
||||
namespace General_Ledger_Service.Services.CsvDocuments
|
||||
{
|
||||
public static class ProfitAndLossCsvDocument
|
||||
{
|
||||
public static byte[] Generate(ProfitAndLossReportDto data)
|
||||
{
|
||||
var csv = new CsvBuilder();
|
||||
csv.AddRow("Section", "Account Code", "Account Name", "Amount");
|
||||
|
||||
AddSection(csv, "Sales", data.Sales);
|
||||
AddSection(csv, "Cost of Sales", data.CostOfSales);
|
||||
csv.AddRow("", "", "GROSS PROFIT", data.GrossProfit);
|
||||
|
||||
AddSection(csv, "Other Income", data.OtherIncome);
|
||||
AddSection(csv, "Distribution Expenses", data.DistributionExpenses);
|
||||
AddSection(csv, "Administration Expenses", data.AdministrationExpenses);
|
||||
AddSection(csv, "Other Expenses", data.OtherExpenses);
|
||||
AddSection(csv, "Financial Expenses", data.FinancialExpenses);
|
||||
|
||||
if (data.Unclassified.Lines.Count > 0)
|
||||
{
|
||||
AddSection(csv, "Unclassified", data.Unclassified);
|
||||
}
|
||||
|
||||
csv.AddRow("", "", "NET PROFIT FOR THE PERIOD", data.NetProfitForPeriod);
|
||||
return csv.Build();
|
||||
}
|
||||
|
||||
private static void AddSection(CsvBuilder csv, string section, ProfitAndLossSectionDto sectionDto)
|
||||
{
|
||||
foreach (var line in sectionDto.Lines)
|
||||
{
|
||||
csv.AddRow(section, line.AccountCode, line.AccountName, line.Amount);
|
||||
}
|
||||
|
||||
csv.AddRow(section, "", $"Total {section}", sectionDto.Total);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
|
||||
namespace General_Ledger_Service.Services.CsvDocuments
|
||||
{
|
||||
public static class TrialBalanceCsvDocument
|
||||
{
|
||||
public static byte[] Generate(List<TrialBalanceRowDto> rows)
|
||||
{
|
||||
var csv = new CsvBuilder();
|
||||
csv.AddRow("Account Code", "Account Name", "Debit", "Credit");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
csv.AddRow(row.AccountCode, row.AccountName, row.Debit == 0 ? null : row.Debit, row.Credit == 0 ? null : row.Credit);
|
||||
}
|
||||
|
||||
csv.AddRow("TOTAL", "", rows.Sum(r => r.Debit), rows.Sum(r => r.Credit));
|
||||
return csv.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.Interfaces.Repositories;
|
||||
using General_Ledger_Service.Interfaces.Services;
|
||||
|
||||
namespace General_Ledger_Service.Services
|
||||
{
|
||||
public class CurrencyService(ICurrencyRepository currencyRepository) : ICurrencyService
|
||||
{
|
||||
private readonly ICurrencyRepository _currencyRepository = currencyRepository;
|
||||
|
||||
public async Task<ApiResponse> ListCurrenciesAsync()
|
||||
{
|
||||
var currencies = await _currencyRepository.GetAllAsync();
|
||||
return ResponseHelpers.Ok("Currencies retrieved successfully", currencies);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Cryptography;
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.Exceptions;
|
||||
using General_Ledger_Service.Interfaces.Services;
|
||||
|
||||
namespace General_Ledger_Service.Services
|
||||
{
|
||||
// Dev-only bootstrap tool: generates the shared secret ApiKeyMiddleware checks. Never
|
||||
// callable outside Development, no matter what - production has no way to reach this branch.
|
||||
// The generated key is only returned, never persisted - it still has to be copied into
|
||||
// appsettings.*.json (this service) and the core backend's matching config by hand.
|
||||
public class DevToolsService(IHostEnvironment hostEnvironment) : IDevToolsService
|
||||
{
|
||||
private readonly IHostEnvironment _hostEnvironment = hostEnvironment;
|
||||
|
||||
public ApiResponse GenerateApiKey()
|
||||
{
|
||||
if (!_hostEnvironment.IsDevelopment())
|
||||
{
|
||||
throw new ForbiddenException("This endpoint is only available in the Development environment");
|
||||
}
|
||||
|
||||
var apiKey = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
return ResponseHelpers.Ok(
|
||||
"API key generated. Copy this into ApiKey:Value in appsettings.Development.json (and appsettings.Production.json / the deploy-time secret store, plus the core backend's matching config) - it is not saved anywhere by this call.",
|
||||
new { apiKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Text.Json;
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.DTOs.FiscalCalendar;
|
||||
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 FiscalCalendarService(
|
||||
IFiscalCalendarRepository fiscalCalendarRepository,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
IUnitOfWork unitOfWork) : IFiscalCalendarService
|
||||
{
|
||||
private readonly IFiscalCalendarRepository _fiscalCalendarRepository = fiscalCalendarRepository;
|
||||
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork;
|
||||
|
||||
public async Task<ApiResponse> CreateFiscalYearAsync(CreateFiscalYearRequest request)
|
||||
{
|
||||
if (request.EndDate <= request.StartDate)
|
||||
{
|
||||
throw new BadRequestException("End date must be after start date");
|
||||
}
|
||||
|
||||
if (await _fiscalCalendarRepository.HasOverlappingFiscalYearRangeAsync(request.StartDate, request.EndDate))
|
||||
{
|
||||
throw new BadRequestException("Fiscal year date range overlaps with an existing fiscal year");
|
||||
}
|
||||
|
||||
var fiscalYear = new FiscalYear
|
||||
{
|
||||
YearName = request.YearName,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
IsClosed = false
|
||||
};
|
||||
|
||||
var generatedPeriods = new List<AccountingPeriod>();
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _fiscalCalendarRepository.AddFiscalYearAsync(fiscalYear);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
if (request.AutoGeneratePeriods)
|
||||
{
|
||||
generatedPeriods = await BuildPeriodsAsync(fiscalYear, request.PeriodCount ?? 12);
|
||||
await _fiscalCalendarRepository.AddPeriodRangeAsync(generatedPeriods);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Ledger,
|
||||
EntityName = "FiscalYear",
|
||||
EntityId = fiscalYear.FiscalYearId.ToString(),
|
||||
Action = AuditAction.Create,
|
||||
Description = $"Fiscal year {fiscalYear.YearName} created",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Fiscal year created successfully", new CreateFiscalYearResponse
|
||||
{
|
||||
FiscalYear = fiscalYear,
|
||||
GeneratedPeriods = generatedPeriods
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> GeneratePeriodsAsync(int fiscalYearId, GeneratePeriodsRequest request)
|
||||
{
|
||||
var fiscalYear = await _fiscalCalendarRepository.GetFiscalYearByIdAsync(fiscalYearId)
|
||||
?? throw new NotFoundException("Fiscal year not found");
|
||||
|
||||
var periods = await BuildPeriodsAsync(fiscalYear, request.PeriodCount ?? 12);
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _fiscalCalendarRepository.AddPeriodRangeAsync(periods);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Ledger,
|
||||
EntityName = "AccountingPeriod",
|
||||
EntityId = fiscalYearId.ToString(),
|
||||
Action = AuditAction.Create,
|
||||
Description = $"{periods.Count} period(s) generated for fiscal year {fiscalYearId}",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Periods generated successfully", periods);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> LockPeriodAsync(int periodId, Dictionary<string, JsonElement> payload)
|
||||
{
|
||||
var rawAction = DataExtractor.GetRequiredString(payload, "action");
|
||||
var reason = DataExtractor.GetRequiredString(payload, "reason");
|
||||
|
||||
if (!Enum.TryParse<PeriodLockAction>(rawAction, ignoreCase: true, out var action))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid action '{rawAction}'. Must be one of: {string.Join(", ", Enum.GetNames<PeriodLockAction>())}.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new BadRequestException("Reason is required");
|
||||
}
|
||||
|
||||
var period = await _fiscalCalendarRepository.GetPeriodByIdAsync(periodId)
|
||||
?? throw new NotFoundException("Period not found");
|
||||
|
||||
if (period.Status == PeriodStatus.Closed && action != PeriodLockAction.Unlock)
|
||||
{
|
||||
throw new BadRequestException("Period is closed; only Unlock is allowed to reopen it");
|
||||
}
|
||||
|
||||
(period.Status, period.LockGl) = action switch
|
||||
{
|
||||
PeriodLockAction.Lock => (PeriodStatus.Locked, true),
|
||||
PeriodLockAction.Unlock => (PeriodStatus.Open, false),
|
||||
PeriodLockAction.SoftClose => (PeriodStatus.SoftClose, false),
|
||||
PeriodLockAction.Close => (PeriodStatus.Closed, true),
|
||||
_ => throw new BadRequestException($"Invalid action '{rawAction}'.")
|
||||
};
|
||||
period.LockReason = reason;
|
||||
|
||||
var log = new PeriodLockLog
|
||||
{
|
||||
PeriodId = periodId,
|
||||
Action = action,
|
||||
ModuleLocked = "GL",
|
||||
Reason = reason
|
||||
};
|
||||
|
||||
var auditAction = action switch
|
||||
{
|
||||
PeriodLockAction.Lock => AuditAction.Lock,
|
||||
PeriodLockAction.Unlock => AuditAction.Unlock,
|
||||
PeriodLockAction.SoftClose => AuditAction.SoftClose,
|
||||
PeriodLockAction.Close => AuditAction.Close,
|
||||
_ => throw new BadRequestException($"Invalid action '{rawAction}'.")
|
||||
};
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _fiscalCalendarRepository.AddPeriodLockLogAsync(log);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Ledger,
|
||||
EntityName = "AccountingPeriod",
|
||||
EntityId = periodId.ToString(),
|
||||
Action = auditAction,
|
||||
Description = $"Period {periodId} action {action}",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Ok("Period lock status updated successfully", new LockPeriodResponse
|
||||
{
|
||||
Period = period,
|
||||
LockLog = log
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> GetPeriodByDateAsync(DateOnly date)
|
||||
{
|
||||
var period = await _fiscalCalendarRepository.GetPeriodByDateAsync(date)
|
||||
?? throw new NotFoundException("Period not found");
|
||||
|
||||
return ResponseHelpers.Ok("Period retrieved successfully", period);
|
||||
}
|
||||
|
||||
private async Task<List<AccountingPeriod>> BuildPeriodsAsync(FiscalYear fiscalYear, int periodCount)
|
||||
{
|
||||
if (periodCount <= 0)
|
||||
{
|
||||
throw new BadRequestException("Period count must be greater than zero");
|
||||
}
|
||||
|
||||
var existingPeriods = await _fiscalCalendarRepository.GetPeriodsByFiscalYearIdAsync(fiscalYear.FiscalYearId);
|
||||
var nextPeriodNumber = existingPeriods.Count > 0 ? existingPeriods[^1].PeriodNumber + 1 : 1;
|
||||
var cursor = existingPeriods.Count > 0 ? existingPeriods[^1].EndDate.AddDays(1) : fiscalYear.StartDate;
|
||||
|
||||
var periods = new List<AccountingPeriod>();
|
||||
for (var i = 0; i < periodCount; i++)
|
||||
{
|
||||
var periodStart = cursor;
|
||||
var periodEnd = periodStart.AddMonths(1).AddDays(-1);
|
||||
|
||||
periods.Add(new AccountingPeriod
|
||||
{
|
||||
FiscalYearId = fiscalYear.FiscalYearId,
|
||||
PeriodNumber = (short)(nextPeriodNumber + i),
|
||||
StartDate = periodStart,
|
||||
EndDate = periodEnd,
|
||||
Status = PeriodStatus.Open,
|
||||
LockGl = false
|
||||
});
|
||||
|
||||
cursor = periodEnd.AddDays(1);
|
||||
}
|
||||
|
||||
if (periods[^1].EndDate > fiscalYear.EndDate)
|
||||
{
|
||||
throw new BadRequestException("Requested period count exceeds the fiscal year's date range");
|
||||
}
|
||||
|
||||
return periods;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
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
|
||||
{
|
||||
// Retrofit - the client always sends a complete, ready-to-post journal entry in one call;
|
||||
// there is no client-facing DRAFT state anymore. CreateAsync validates, inserts, balance-checks,
|
||||
// period-lock-checks, and posts, all in one transaction. lines is required for a plain create
|
||||
// and for a PartialAdjustment correction (previously deferred to a separate SetLines call);
|
||||
// FullReversal still auto-builds its lines by mirroring the original.
|
||||
public class JournalEntryService(
|
||||
IJournalEntryRepository journalEntryRepository,
|
||||
IAccountRepository accountRepository,
|
||||
ICostCenterRepository costCenterRepository,
|
||||
IFiscalCalendarRepository fiscalCalendarRepository,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
IUnitOfWork unitOfWork) : IJournalEntryService
|
||||
{
|
||||
private readonly IJournalEntryRepository _journalEntryRepository = journalEntryRepository;
|
||||
private readonly IAccountRepository _accountRepository = accountRepository;
|
||||
private readonly ICostCenterRepository _costCenterRepository = costCenterRepository;
|
||||
private readonly IFiscalCalendarRepository _fiscalCalendarRepository = fiscalCalendarRepository;
|
||||
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork;
|
||||
|
||||
public async Task<ApiResponse> CreateAsync(CreateJournalEntryRequest request)
|
||||
{
|
||||
var period = await _fiscalCalendarRepository.GetPeriodByIdAsync(request.PeriodId)
|
||||
?? throw new NotFoundException("Period not found");
|
||||
|
||||
var entry = new JournalEntry
|
||||
{
|
||||
JournalNo = await GenerateJournalNoAsync(request.EntryDate.Year),
|
||||
PeriodId = request.PeriodId,
|
||||
EntryDate = request.EntryDate,
|
||||
JournalType = request.JournalType,
|
||||
SourceModule = request.SourceModule,
|
||||
Reference = request.Reference,
|
||||
Narration = request.Narration,
|
||||
PreparedBy = request.PreparedBy,
|
||||
IsPosted = false,
|
||||
IsReversed = false,
|
||||
EntryStatus = JournalEntryStatus.Draft
|
||||
};
|
||||
|
||||
List<JournalEntryLineRequest>? linesToBuild = null;
|
||||
List<JournalEntryLine>? mirroredLines = null;
|
||||
JournalEntry? original = null;
|
||||
JournalCorrectionType correctionType = default;
|
||||
var isCorrection = request.Correction != null;
|
||||
var auditAction = AuditAction.Create;
|
||||
|
||||
if (isCorrection)
|
||||
{
|
||||
var correction = request.Correction!;
|
||||
|
||||
if (!Enum.TryParse(correction.CorrectionType, ignoreCase: true, out correctionType))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid correctionType '{correction.CorrectionType}'. Must be one of: {string.Join(", ", Enum.GetNames<JournalCorrectionType>())}.");
|
||||
}
|
||||
|
||||
original = await _journalEntryRepository.GetJournalEntryByJournalNoAsync(correction.OriginalJournalNo)
|
||||
?? throw new NotFoundException("Original journal entry not found");
|
||||
|
||||
if (original.EntryStatus != JournalEntryStatus.Posted)
|
||||
{
|
||||
throw new BadRequestException("Original journal entry must be posted before it can be corrected");
|
||||
}
|
||||
|
||||
if (correctionType == JournalCorrectionType.FullReversal)
|
||||
{
|
||||
mirroredLines = [.. original.Lines.Select(l => new JournalEntryLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
DebitAmount = l.CreditAmount,
|
||||
CreditAmount = l.DebitAmount,
|
||||
CurrencyCode = l.CurrencyCode,
|
||||
ExchangeRate = l.ExchangeRate,
|
||||
BaseDebit = l.BaseCredit,
|
||||
BaseCredit = l.BaseDebit,
|
||||
CostCenterId = l.CostCenterId,
|
||||
Memo = l.Memo
|
||||
})];
|
||||
entry.ReversalOfId = original.JournalId;
|
||||
auditAction = AuditAction.Reverse;
|
||||
}
|
||||
else
|
||||
{
|
||||
// PartialAdjustment: lines are now required inline, same as a plain create -
|
||||
// there's no more follow-up call to supply them afterward.
|
||||
linesToBuild = request.Lines;
|
||||
auditAction = AuditAction.Adjust;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
linesToBuild = request.Lines;
|
||||
}
|
||||
|
||||
if (mirroredLines is null && (linesToBuild is null || linesToBuild.Count == 0))
|
||||
{
|
||||
throw new BadRequestException("lines is required");
|
||||
}
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _journalEntryRepository.AddJournalEntryAsync(entry);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
List<JournalEntryLine> finalLines;
|
||||
|
||||
if (mirroredLines != null)
|
||||
{
|
||||
foreach (var line in mirroredLines)
|
||||
{
|
||||
line.JournalId = entry.JournalId;
|
||||
}
|
||||
await _journalEntryRepository.AddJournalEntryLinesAsync(mirroredLines);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
finalLines = mirroredLines;
|
||||
}
|
||||
else
|
||||
{
|
||||
var builtLines = new List<JournalEntryLine>();
|
||||
foreach (var lineRequest in linesToBuild!)
|
||||
{
|
||||
var line = await BuildLineAsync(lineRequest);
|
||||
line.JournalId = entry.JournalId;
|
||||
builtLines.Add(line);
|
||||
}
|
||||
await _journalEntryRepository.AddJournalEntryLinesAsync(builtLines);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
finalLines = builtLines;
|
||||
}
|
||||
|
||||
entry.Lines = finalLines;
|
||||
|
||||
if (isCorrection)
|
||||
{
|
||||
var correctionRow = new JournalCorrection
|
||||
{
|
||||
OriginalJournalId = original!.JournalId,
|
||||
CorrectionJournalId = entry.JournalId,
|
||||
CorrectionType = correctionType,
|
||||
Reason = request.Correction!.Reason,
|
||||
CorrectedBy = request.Correction!.CorrectedBy
|
||||
};
|
||||
await _journalEntryRepository.AddJournalCorrectionAsync(correctionRow);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Ledger,
|
||||
EntityName = "JournalEntry",
|
||||
EntityId = entry.JournalId.ToString(),
|
||||
Action = auditAction,
|
||||
Description = $"Journal entry {entry.JournalNo} created",
|
||||
PerformedBy = request.PreparedBy
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Immediately post - there is no client-facing DRAFT state to leave this in.
|
||||
var totalDebit = finalLines.Sum(l => l.DebitAmount);
|
||||
var totalCredit = finalLines.Sum(l => l.CreditAmount);
|
||||
if (totalDebit != totalCredit)
|
||||
{
|
||||
throw new BadRequestException("Journal entry does not balance");
|
||||
}
|
||||
|
||||
if (period.Status is PeriodStatus.Locked or PeriodStatus.Closed || period.LockGl)
|
||||
{
|
||||
throw new BadRequestException("Period is locked");
|
||||
}
|
||||
|
||||
entry.IsPosted = true;
|
||||
entry.EntryStatus = JournalEntryStatus.Posted;
|
||||
|
||||
if (correctionType == JournalCorrectionType.FullReversal)
|
||||
{
|
||||
original!.EntryStatus = JournalEntryStatus.Reversed;
|
||||
original.IsReversed = true;
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Ledger,
|
||||
EntityName = "JournalEntry",
|
||||
EntityId = entry.JournalId.ToString(),
|
||||
Action = AuditAction.Post,
|
||||
Description = $"Journal entry {entry.JournalNo} posted",
|
||||
PerformedBy = null
|
||||
});
|
||||
|
||||
if (correctionType == JournalCorrectionType.FullReversal)
|
||||
{
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Ledger,
|
||||
EntityName = "JournalEntry",
|
||||
EntityId = original!.JournalId.ToString(),
|
||||
Action = AuditAction.Reverse,
|
||||
Description = $"Journal entry {original.JournalNo} reversed by {entry.JournalNo}",
|
||||
PerformedBy = null
|
||||
});
|
||||
}
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Journal entry created and posted successfully", entry);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> GetByJournalNoAsync(string journalNo)
|
||||
{
|
||||
var entry = await _journalEntryRepository.GetJournalEntryByJournalNoAsync(journalNo)
|
||||
?? throw new NotFoundException("Journal entry not found");
|
||||
|
||||
return ResponseHelpers.Ok("Journal entry retrieved successfully", entry);
|
||||
}
|
||||
|
||||
private async Task<JournalEntryLine> BuildLineAsync(JournalEntryLineRequest request)
|
||||
{
|
||||
var account = await _accountRepository.GetByCodeAsync(request.AccountCode)
|
||||
?? throw new NotFoundException($"Account {request.AccountCode} not found");
|
||||
|
||||
if (!account.IsPostable)
|
||||
{
|
||||
throw new BadRequestException($"Account {account.AccountCode} is not postable");
|
||||
}
|
||||
|
||||
long? costCenterId = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.CostCenterCode))
|
||||
{
|
||||
var costCenter = await _costCenterRepository.GetByCodeAsync(request.CostCenterCode)
|
||||
?? throw new NotFoundException($"Cost center {request.CostCenterCode} not found");
|
||||
costCenterId = costCenter.CostCenterId;
|
||||
}
|
||||
|
||||
return new JournalEntryLine
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
DebitAmount = request.DebitAmount,
|
||||
CreditAmount = request.CreditAmount,
|
||||
CurrencyCode = account.CurrencyCode,
|
||||
ExchangeRate = 1,
|
||||
BaseDebit = request.DebitAmount,
|
||||
BaseCredit = request.CreditAmount,
|
||||
CostCenterId = costCenterId,
|
||||
Memo = request.Memo
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> GenerateJournalNoAsync(int year)
|
||||
{
|
||||
var count = await _journalEntryRepository.CountJournalEntriesForYearAsync(year);
|
||||
return $"JV-{year}-{(count + 1):D6}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 3 (LKAS 1) - retrofit 2026-07-31, classified
|
||||
// presentation (Non-Current/Current split) driven by BalanceSheetReportDto's sections,
|
||||
// rather than the old rollup-by-depth-1 grouping. Each section is already leaf rows + a
|
||||
// server-computed Total (Accounting_System_Design_LKAS.md Section 11.3), so the renderer
|
||||
// just lists lines and prints the given totals - no rendering-layer arithmetic.
|
||||
public class BalanceSheetPdfDocument(string companyName, string currencyCode, DateOnly asOfDate, BalanceSheetReportDto data) : IDocument
|
||||
{
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
ReportPageHeader.Compose(page, companyName, "Statement of Financial Position", ReportPageHeader.PointInTimePeriodLine(asOfDate), currencyCode);
|
||||
|
||||
page.Content().PaddingTop(10).Column(column =>
|
||||
{
|
||||
void RenderSection(string title, BalanceSheetSectionDto section, string totalLabel)
|
||||
{
|
||||
if (section.Lines.Count == 0) return;
|
||||
|
||||
column.Item().PaddingTop(8).Text(title).Bold();
|
||||
foreach (var line in section.Lines)
|
||||
{
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(line.AccountName).FontFamily(Fonts.Consolas);
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(line.Balance));
|
||||
});
|
||||
}
|
||||
column.Item().PaddingTop(2).LineHorizontal(0.75f);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(totalLabel).Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(section.Total)).Bold();
|
||||
});
|
||||
}
|
||||
|
||||
column.Item().Text("ASSETS").Bold().FontSize(12);
|
||||
RenderSection("Non-Current Assets", data.NonCurrentAssets, "Total Non-Current Assets");
|
||||
RenderSection("Current Assets", data.CurrentAssets, "Total Current Assets");
|
||||
RenderSection("Unclassified Assets", data.UnclassifiedAssets, "Total Unclassified Assets");
|
||||
|
||||
column.Item().PaddingTop(3).LineHorizontal(1.5f);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text("TOTAL ASSETS").Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(data.TotalAssets)).Bold();
|
||||
});
|
||||
|
||||
column.Item().PaddingTop(15).Text("EQUITY AND LIABILITIES").Bold().FontSize(12);
|
||||
RenderSection("Equity", data.Equity, "Total Equity");
|
||||
RenderSection("Non-Current Liabilities", data.NonCurrentLiabilities, "Total Non-Current Liabilities");
|
||||
RenderSection("Current Liabilities", data.CurrentLiabilities, "Total Current Liabilities");
|
||||
RenderSection("Unclassified Liabilities", data.UnclassifiedLiabilities, "Total Unclassified Liabilities");
|
||||
|
||||
column.Item().PaddingTop(3).LineHorizontal(1.5f);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text("TOTAL EQUITY AND LIABILITIES").Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(data.TotalEquityAndLiabilities)).Bold();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 7 - not an LKAS statement, a management report.
|
||||
// Flat table including a Period column, since a budget can span multiple periods (Section 7's
|
||||
// own open question - grouping by period is a possible future enhancement, not required here).
|
||||
public class BudgetVsActualPdfDocument(string companyName, string currencyCode, long budgetId, List<BudgetVsActualRowDto> rows) : IDocument
|
||||
{
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
ReportPageHeader.Compose(page, companyName, "Budget vs Actual Report", $"Budget ID: {budgetId}", currencyCode);
|
||||
|
||||
page.Content().PaddingTop(10).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn(4);
|
||||
columns.RelativeColumn(1);
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Text("Account Code").Bold();
|
||||
header.Cell().Text("Account Name").Bold();
|
||||
header.Cell().AlignRight().Text("Period").Bold();
|
||||
header.Cell().AlignRight().Text("Budgeted").Bold();
|
||||
header.Cell().AlignRight().Text("Actual").Bold();
|
||||
header.Cell().AlignRight().Text("Variance").Bold();
|
||||
header.Cell().ColumnSpan(6).PaddingTop(3).LineHorizontal(1);
|
||||
});
|
||||
|
||||
foreach (var row in rows.OrderBy(r => r.PeriodId).ThenBy(r => r.AccountCode))
|
||||
{
|
||||
table.Cell().Text(row.AccountCode);
|
||||
table.Cell().Text(row.AccountName);
|
||||
table.Cell().AlignRight().Text(row.PeriodId.ToString());
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(row.BudgetedAmount));
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(row.ActualAmount));
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(row.Variance));
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(6).PaddingTop(3).LineHorizontal(1);
|
||||
table.Cell().Text("");
|
||||
table.Cell().Text("Total").Bold();
|
||||
table.Cell().Text("");
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(rows.Sum(r => r.BudgetedAmount))).Bold();
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(rows.Sum(r => r.ActualAmount))).Bold();
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(rows.Sum(r => r.Variance))).Bold();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 5 (LKAS 7, full statement). Retrofit 2026-07-22:
|
||||
// no printed opening/closing cash balance (still in the Json/Csv data for the reconciliation
|
||||
// check, just not displayed here, matching the reference template); nonCashAdjustments and
|
||||
// workingCapitalChanges are combined and re-bucketed by sign at render time into "Additions
|
||||
// to Cash"/"Subtractions From Cash" rather than kept as two separate lists. Working-capital
|
||||
// lines now use the account's own Direction ("Increase"/"Decrease") for their wording, since
|
||||
// that's no longer derivable from ChangeAmount's sign alone (see the DTO's comment).
|
||||
public class CashFlowPdfDocument(string companyName, string currencyCode, CashFlowReportDto data) : IDocument
|
||||
{
|
||||
private record CashMovementLine(string Description, decimal Amount);
|
||||
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
var operating = data.OperatingActivities;
|
||||
var operatingProfitBeforeWorkingCapital = operating.ProfitForPeriod + operating.NonCashAdjustments.Sum(a => a.Amount);
|
||||
|
||||
var movements = operating.NonCashAdjustments
|
||||
.Select(a => new CashMovementLine(a.Description, a.Amount))
|
||||
.Concat(operating.WorkingCapitalChanges.Select(c => new CashMovementLine($"{c.Direction} in {c.AccountName}", c.ChangeAmount)))
|
||||
.ToList();
|
||||
var additions = movements.Where(m => m.Amount > 0).ToList();
|
||||
var subtractions = movements.Where(m => m.Amount <= 0).ToList();
|
||||
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
ReportPageHeader.Compose(page, companyName, "Statement of Cash Flows", ReportPageHeader.RangePeriodLine(data.PeriodStart, data.PeriodEnd), currencyCode);
|
||||
|
||||
page.Content().PaddingTop(10).Column(column =>
|
||||
{
|
||||
column.Item().Text("Cash Flows from Operating Activities").Bold();
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text("Profit for the period");
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(operating.ProfitForPeriod));
|
||||
});
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text("Operating profit before working capital changes");
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(operatingProfitBeforeWorkingCapital));
|
||||
});
|
||||
|
||||
if (additions.Count > 0)
|
||||
{
|
||||
column.Item().PaddingTop(5).Text("Additions to Cash");
|
||||
foreach (var line in additions)
|
||||
{
|
||||
column.Item().PaddingLeft(10).Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(line.Description);
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(line.Amount));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (subtractions.Count > 0)
|
||||
{
|
||||
column.Item().PaddingTop(5).Text("Subtractions From Cash");
|
||||
foreach (var line in subtractions)
|
||||
{
|
||||
column.Item().PaddingLeft(10).Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(line.Description);
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(line.Amount));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
column.Item().PaddingTop(3).LineHorizontal(1);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text("Net Cash from Operating Activities").Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(operating.NetCashFromOperatingActivities)).Bold();
|
||||
});
|
||||
|
||||
column.Item().PaddingTop(15).Text("Cash Flows from Investing Activities").Bold();
|
||||
foreach (var line in data.InvestingActivities.Lines)
|
||||
{
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(line.AccountName);
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(line.Amount));
|
||||
});
|
||||
}
|
||||
column.Item().PaddingTop(3).LineHorizontal(1);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
var label = data.InvestingActivities.NetCashFromInvestingActivities < 0 ? "Net Cash used in Investing Activities" : "Net Cash from Investing Activities";
|
||||
r.RelativeItem(4).Text(label).Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(data.InvestingActivities.NetCashFromInvestingActivities)).Bold();
|
||||
});
|
||||
|
||||
column.Item().PaddingTop(15).Text("Cash Flows from Financing Activities").Bold();
|
||||
foreach (var line in data.FinancingActivities.Lines)
|
||||
{
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(line.AccountName);
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(line.Amount));
|
||||
});
|
||||
}
|
||||
column.Item().PaddingTop(3).LineHorizontal(1);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
var label = data.FinancingActivities.NetCashFromFinancingActivities < 0 ? "Net Cash used in Financing Activities" : "Net Cash from Financing Activities";
|
||||
r.RelativeItem(4).Text(label).Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(data.FinancingActivities.NetCashFromFinancingActivities)).Bold();
|
||||
});
|
||||
|
||||
column.Item().PaddingTop(15).LineHorizontal(1.5f);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
var label = data.NetIncreaseDecreaseInCash < 0 ? "Net Decrease in Cash and Cash Equivalents" : "Net Increase in Cash and Cash Equivalents";
|
||||
r.RelativeItem(4).Text(label).Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(data.NetIncreaseDecreaseInCash)).Bold();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 6 - one continuous table (not separate boxed
|
||||
// sections per account), account boundaries marked by an inline full-width group-header row.
|
||||
// Response is already sorted by accountCode then entryDate - no re-sorting needed here.
|
||||
public class GeneralLedgerPdfDocument(string companyName, string currencyCode, DateOnly periodStart, DateOnly periodEnd, List<GeneralLedgerLineDto> rows) : IDocument
|
||||
{
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
ReportPageHeader.Compose(page, companyName, "General Ledger", ReportPageHeader.RangePeriodLine(periodStart, periodEnd), currencyCode);
|
||||
|
||||
page.Content().PaddingTop(10).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn(4);
|
||||
columns.RelativeColumn(3);
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Text("Date").Bold();
|
||||
header.Cell().Text("Particulars").Bold();
|
||||
header.Cell().Text("Description/Reference (J.R.)").Bold();
|
||||
header.Cell().AlignRight().Text("Debit").Bold();
|
||||
header.Cell().AlignRight().Text("Credit").Bold();
|
||||
header.Cell().AlignRight().Text("Balance").Bold();
|
||||
header.Cell().ColumnSpan(6).PaddingTop(3).LineHorizontal(1);
|
||||
});
|
||||
|
||||
string? currentAccountCode = null;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.AccountCode != currentAccountCode)
|
||||
{
|
||||
currentAccountCode = row.AccountCode;
|
||||
table.Cell().ColumnSpan(6).Background(Colors.Grey.Lighten3).Padding(3)
|
||||
.Text($"Account: {row.AccountCode} — {row.AccountName}").Bold();
|
||||
}
|
||||
|
||||
table.Cell().Text(row.EntryDate.ToString("yyyy-MM-dd"));
|
||||
table.Cell().Text(row.Narration ?? string.Empty);
|
||||
table.Cell().Text(row.JournalNo);
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmountOrBlank(row.DebitAmount));
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmountOrBlank(row.CreditAmount));
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(row.RunningBalance));
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(6).PaddingTop(3).LineHorizontal(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 8, redesigned 2026-07-22 - Accounting_System_Design_LKAS.md
|
||||
// Section 11.7. A single Profit-Before-Tax-to-Balance-Tax-Payable computation (current tax
|
||||
// only, no deferred tax) - the standard corporate income tax return shape, replacing the
|
||||
// 2026-07-21 VAT/WHT/NBT per-tax_code summary entirely. Uses the fuller company identity
|
||||
// block (address/TIN/BRN) since a tax computation conventionally carries those.
|
||||
public class IncomeTaxComputationPdfDocument(string companyName, string companyAddress, string companyTin, string companyBrn, string currencyCode, IncomeTaxComputationDto data) : IDocument
|
||||
{
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
ReportPageHeader.ComposeWithEntityDetails(
|
||||
page, companyName, companyAddress, companyTin, companyBrn,
|
||||
"Income Tax Computation", ReportPageHeader.RangePeriodLine(data.PeriodStart, data.PeriodEnd), currencyCode);
|
||||
|
||||
page.Content().PaddingTop(10).Column(column =>
|
||||
{
|
||||
Line(column, "Profit Before Tax", data.ProfitBeforeTax);
|
||||
Line(column, "Add: Non-Deductible Expenses", data.NonDeductibleExpenses);
|
||||
Line(column, "Less: Allowable Deductions", -data.AllowableDeductions);
|
||||
Total(column, "Adjusted Business Profit", data.AdjustedBusinessProfit);
|
||||
|
||||
Line(column, "Add: Other Taxable Income", data.OtherTaxableIncome);
|
||||
Total(column, "Assessable Income", data.AssessableIncome);
|
||||
|
||||
Line(column, "Less: Qualifying Payments / Reliefs", -data.QualifyingPaymentsReliefs);
|
||||
Total(column, "Taxable Income", data.TaxableIncome);
|
||||
|
||||
column.Item().PaddingTop(10).Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text($"Corporate Income Tax @ {data.TaxRatePercent:0.00}%");
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(data.CorporateIncomeTax));
|
||||
});
|
||||
Line(column, "Add: Surcharge / Education Levy", data.SurchargeAmount);
|
||||
Total(column, "Gross Tax Liability", data.GrossTaxLiability);
|
||||
|
||||
Line(column, "Less: APIT Credit", -data.ApitCredit);
|
||||
Line(column, "Less: WHT Credit", -data.WhtCredit);
|
||||
Line(column, "Less: Quarterly Tax Payments", -data.QuarterlyTaxPayments);
|
||||
|
||||
column.Item().PaddingTop(10).LineHorizontal(1.5f);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
var label = data.BalanceTaxPayable < 0 ? "BALANCE TAX REFUNDABLE" : "BALANCE TAX PAYABLE";
|
||||
r.RelativeItem(4).Text(label).Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(data.BalanceTaxPayable)).Bold();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void Line(ColumnDescriptor column, string label, decimal amount)
|
||||
{
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(label);
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(amount));
|
||||
});
|
||||
}
|
||||
|
||||
private static void Total(ColumnDescriptor column, string label, decimal amount)
|
||||
{
|
||||
column.Item().PaddingTop(3).LineHorizontal(1);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(label).Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(amount)).Bold();
|
||||
});
|
||||
column.Item().PaddingTop(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// Shared amount-formatting rules for every report PDF (05_LKAS_Report_PDF_Templates.md
|
||||
// Section 9): thousands separator, 2 decimal places, round-half-up, negatives in
|
||||
// parentheses. Same rounding rule as CLAUDE.md Rule 13's FormatCellValue.
|
||||
public static class PdfFormatting
|
||||
{
|
||||
public static string FormatAmount(decimal value)
|
||||
{
|
||||
var rounded = Math.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
return rounded < 0 ? $"({Math.Abs(rounded):N2})" : rounded.ToString("N2");
|
||||
}
|
||||
|
||||
// Blank instead of "0.00" - Trial Balance's Debit/Credit split (Section 2).
|
||||
public static string FormatAmountOrBlank(decimal value) =>
|
||||
value == 0 ? string.Empty : FormatAmount(value);
|
||||
|
||||
// "—" instead of "0.00" - Tax Summary's Output/Input Tax columns (Section 8).
|
||||
public static string FormatAmountOrDash(decimal value) =>
|
||||
value == 0 ? "—" : FormatAmount(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 4 (LKAS 1). Retrofit 2026-07-22: restructured
|
||||
// around accounts.income_statement_section - Sales/Cost of Sales -> Gross Profit, then
|
||||
// Other Income and four expense groups -> Net Profit for the Period. Both GrossProfit and
|
||||
// NetProfitForPeriod are already computed server-side (ReportService.BuildProfitAndLossReportAsync).
|
||||
public class ProfitAndLossPdfDocument(string companyName, string currencyCode, ProfitAndLossReportDto data) : IDocument
|
||||
{
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
ReportPageHeader.Compose(page, companyName, "Statement of Profit or Loss", ReportPageHeader.RangePeriodLine(data.PeriodStart, data.PeriodEnd), currencyCode);
|
||||
|
||||
page.Content().PaddingTop(10).Column(column =>
|
||||
{
|
||||
ComposeSection(column, "Sales", data.Sales);
|
||||
ComposeSection(column, "Cost of Sales", data.CostOfSales);
|
||||
ComposeTotal(column, "GROSS PROFIT", data.GrossProfit, topRule: true);
|
||||
|
||||
ComposeSection(column, "Other Income", data.OtherIncome);
|
||||
ComposeSection(column, "Distribution Expenses", data.DistributionExpenses);
|
||||
ComposeSection(column, "Administration Expenses", data.AdministrationExpenses);
|
||||
ComposeSection(column, "Other Expenses", data.OtherExpenses);
|
||||
ComposeSection(column, "Financial Expenses", data.FinancialExpenses);
|
||||
|
||||
if (data.Unclassified.Lines.Count > 0)
|
||||
{
|
||||
ComposeSection(column, "Unclassified", data.Unclassified);
|
||||
}
|
||||
|
||||
ComposeTotal(column, "NET PROFIT FOR THE PERIOD", data.NetProfitForPeriod, topRule: true, doubleRule: true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void ComposeSection(ColumnDescriptor column, string title, ProfitAndLossSectionDto section)
|
||||
{
|
||||
if (section.Lines.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
column.Item().PaddingTop(10).Text(title).Bold();
|
||||
foreach (var line in section.Lines)
|
||||
{
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(line.AccountName);
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(line.Amount));
|
||||
});
|
||||
}
|
||||
|
||||
column.Item().PaddingTop(3).LineHorizontal(1);
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text($"Total {title}").Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(section.Total)).Bold();
|
||||
});
|
||||
}
|
||||
|
||||
private static void ComposeTotal(ColumnDescriptor column, string label, decimal amount, bool topRule, bool doubleRule = false)
|
||||
{
|
||||
if (topRule)
|
||||
{
|
||||
column.Item().PaddingTop(10).LineHorizontal(doubleRule ? 1.5f : 1);
|
||||
}
|
||||
|
||||
column.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem(4).Text(label).Bold();
|
||||
r.RelativeItem(1).AlignRight().Text(PdfFormatting.FormatAmount(amount)).Bold();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// Shared header/footer block for every report PDF (05_LKAS_Report_PDF_Templates.md Section 1)
|
||||
// - satisfies LKAS 1.51's minimum for every statement: entity name, report title, period
|
||||
// covered, presentation currency. Applied to the non-LKAS reports too, for consistency.
|
||||
public static class ReportPageHeader
|
||||
{
|
||||
public static void Compose(PageDescriptor page, string companyName, string reportTitle, string periodLine, string currencyCode)
|
||||
{
|
||||
page.Header().Column(column =>
|
||||
{
|
||||
column.Item().Text(companyName).FontSize(14).Bold();
|
||||
column.Item().Text(reportTitle).FontSize(12).Bold();
|
||||
column.Item().Text(periodLine).FontSize(10);
|
||||
column.Item().Text($"(Amounts in {currencyCode})").FontSize(9).Italic();
|
||||
column.Item().PaddingTop(5).LineHorizontal(1);
|
||||
});
|
||||
|
||||
ComposeFooter(page);
|
||||
}
|
||||
|
||||
// Fuller identity block for the Income Tax Computation report (02_Phase_By_Phase_Development_Plan.md
|
||||
// Section 1.9) - a corporate tax computation conventionally carries the filer's address
|
||||
// and tax reference numbers, which the other six reports have no need for.
|
||||
public static void ComposeWithEntityDetails(PageDescriptor page, string companyName, string address, string tin, string brn, string reportTitle, string periodLine, string currencyCode)
|
||||
{
|
||||
page.Header().Column(column =>
|
||||
{
|
||||
column.Item().Text(companyName).FontSize(14).Bold();
|
||||
column.Item().Text(address).FontSize(9);
|
||||
column.Item().Text($"TIN: {tin} BRN: {brn}").FontSize(9);
|
||||
column.Item().Text(reportTitle).FontSize(12).Bold();
|
||||
column.Item().Text(periodLine).FontSize(10);
|
||||
column.Item().Text($"(Amounts in {currencyCode})").FontSize(9).Italic();
|
||||
column.Item().PaddingTop(5).LineHorizontal(1);
|
||||
});
|
||||
|
||||
ComposeFooter(page);
|
||||
}
|
||||
|
||||
private static void ComposeFooter(PageDescriptor page)
|
||||
{
|
||||
page.Footer().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text(text =>
|
||||
{
|
||||
text.Span("Generated ").FontSize(8);
|
||||
text.Span(DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm") + " UTC").FontSize(8);
|
||||
});
|
||||
row.RelativeItem().AlignRight().Text(text =>
|
||||
{
|
||||
text.Span("Page ").FontSize(8);
|
||||
text.CurrentPageNumber().FontSize(8);
|
||||
text.Span(" of ").FontSize(8);
|
||||
text.TotalPages().FontSize(8);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Two period-line conventions (Section 1): point-in-time reports vs period reports.
|
||||
public static string PointInTimePeriodLine(DateOnly asOfDate) => $"As at {asOfDate:yyyy-MM-dd}";
|
||||
public static string RangePeriodLine(DateOnly periodStart, DateOnly periodEnd) => $"For the period {periodStart:yyyy-MM-dd} to {periodEnd:yyyy-MM-dd}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services.PdfDocuments
|
||||
{
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 2 - not an LKAS statement, a working paper.
|
||||
// Retrofit 2026-07-22: flat list (no depth/indentation) - portrait, matching the
|
||||
// reference template's plain Account name | Debit | Credit table.
|
||||
public class TrialBalancePdfDocument(string companyName, string currencyCode, DateOnly asOfDate, List<TrialBalanceRowDto> rows) : IDocument
|
||||
{
|
||||
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
|
||||
|
||||
public void Compose(IDocumentContainer container)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
ReportPageHeader.Compose(page, companyName, "Trial Balance", ReportPageHeader.PointInTimePeriodLine(asOfDate), currencyCode);
|
||||
|
||||
page.Content().PaddingTop(10).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn(5);
|
||||
columns.RelativeColumn(2);
|
||||
columns.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Text("Account Code").Bold();
|
||||
header.Cell().Text("Account Name").Bold();
|
||||
header.Cell().AlignRight().Text("Debit").Bold();
|
||||
header.Cell().AlignRight().Text("Credit").Bold();
|
||||
header.Cell().ColumnSpan(4).PaddingTop(3).LineHorizontal(1);
|
||||
});
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Cell().Text(row.AccountCode);
|
||||
table.Cell().Text(row.AccountName);
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmountOrBlank(row.Debit));
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmountOrBlank(row.Credit));
|
||||
}
|
||||
|
||||
var totalDebit = rows.Sum(r => r.Debit);
|
||||
var totalCredit = rows.Sum(r => r.Credit);
|
||||
|
||||
table.Cell().ColumnSpan(4).PaddingTop(3).LineHorizontal(1);
|
||||
table.Cell().Text("");
|
||||
table.Cell().Text("TOTAL").Bold();
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(totalDebit)).Bold();
|
||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(totalCredit)).Bold();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.DTOs.JournalEntries;
|
||||
using General_Ledger_Service.DTOs.RecurringTemplates;
|
||||
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 RecurringTemplateService(
|
||||
IRecurringJournalTemplateRepository templateRepository,
|
||||
IAccountRepository accountRepository,
|
||||
IFiscalCalendarRepository fiscalCalendarRepository,
|
||||
IJournalEntryService journalEntryService,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
IUnitOfWork unitOfWork) : IRecurringTemplateService
|
||||
{
|
||||
private readonly IRecurringJournalTemplateRepository _templateRepository = templateRepository;
|
||||
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(CreateRecurringTemplateRequest request)
|
||||
{
|
||||
if (!Enum.TryParse<RecurrencePattern>(request.RecurrencePattern, ignoreCase: true, out var recurrencePattern))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid recurrencePattern '{request.RecurrencePattern}'. Must be one of: {string.Join(", ", Enum.GetNames<RecurrencePattern>())}.");
|
||||
}
|
||||
|
||||
if (request.Lines.Count == 0)
|
||||
{
|
||||
throw new BadRequestException("Recurring template must have at least one line");
|
||||
}
|
||||
|
||||
var totalDebit = request.Lines.Sum(l => l.DebitAmount);
|
||||
var totalCredit = request.Lines.Sum(l => l.CreditAmount);
|
||||
if (totalDebit != totalCredit)
|
||||
{
|
||||
throw new BadRequestException("Recurring template lines do not balance");
|
||||
}
|
||||
|
||||
var lines = new List<RecurringJournalTemplateLine>();
|
||||
foreach (var lineRequest in request.Lines)
|
||||
{
|
||||
var account = await _accountRepository.GetByCodeAsync(lineRequest.AccountCode)
|
||||
?? throw new NotFoundException($"Account {lineRequest.AccountCode} not found");
|
||||
|
||||
if (!account.IsPostable)
|
||||
{
|
||||
throw new BadRequestException($"Account {account.AccountCode} is not postable");
|
||||
}
|
||||
|
||||
lines.Add(new RecurringJournalTemplateLine
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
DebitAmount = lineRequest.DebitAmount,
|
||||
CreditAmount = lineRequest.CreditAmount
|
||||
});
|
||||
}
|
||||
|
||||
var template = new RecurringJournalTemplate
|
||||
{
|
||||
TemplateName = request.TemplateName,
|
||||
RecurrencePattern = recurrencePattern,
|
||||
NextRunDate = request.NextRunDate,
|
||||
EndDate = request.EndDate,
|
||||
IsActive = true,
|
||||
Lines = lines
|
||||
};
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _templateRepository.AddAsync(template);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Administrative,
|
||||
EntityName = "RecurringJournalTemplate",
|
||||
EntityId = template.TemplateId.ToString(),
|
||||
Action = AuditAction.Create,
|
||||
Description = $"Recurring template {template.TemplateName} created",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Recurring template created successfully", template);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> GenerateAsync(long templateId)
|
||||
{
|
||||
var template = await _templateRepository.GetByIdAsync(templateId)
|
||||
?? throw new NotFoundException("Recurring template not found");
|
||||
|
||||
if (!template.IsActive)
|
||||
{
|
||||
throw new BadRequestException("Recurring template is not active");
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (template.NextRunDate > today)
|
||||
{
|
||||
throw new BadRequestException("Recurring template is not yet due to run");
|
||||
}
|
||||
|
||||
var period = await _fiscalCalendarRepository.GetPeriodByDateAsync(today)
|
||||
?? throw new NotFoundException("No accounting period covers today's date");
|
||||
|
||||
var lines = new List<JournalEntryLineRequest>();
|
||||
foreach (var line in template.Lines)
|
||||
{
|
||||
var account = await _accountRepository.GetByIdAsync(line.AccountId);
|
||||
lines.Add(new JournalEntryLineRequest
|
||||
{
|
||||
AccountCode = account!.AccountCode,
|
||||
DebitAmount = line.DebitAmount,
|
||||
CreditAmount = line.CreditAmount
|
||||
});
|
||||
}
|
||||
|
||||
var journalRequest = new CreateJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = today,
|
||||
JournalType = "RECURRING",
|
||||
Reference = template.TemplateName,
|
||||
Narration = $"Generated from recurring template {template.TemplateName}",
|
||||
Lines = lines
|
||||
};
|
||||
|
||||
var journalResponse = await _journalEntryService.CreateAsync(journalRequest);
|
||||
|
||||
template.NextRunDate = template.RecurrencePattern switch
|
||||
{
|
||||
RecurrencePattern.Monthly => template.NextRunDate.AddMonths(1),
|
||||
RecurrencePattern.Quarterly => template.NextRunDate.AddMonths(3),
|
||||
RecurrencePattern.Annual => template.NextRunDate.AddYears(1),
|
||||
_ => template.NextRunDate
|
||||
};
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Recurring journal entry generated successfully", journalResponse.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using General_Ledger_Service.Enums;
|
||||
using General_Ledger_Service.Exceptions;
|
||||
using General_Ledger_Service.Interfaces.Repositories;
|
||||
using General_Ledger_Service.Interfaces.Services;
|
||||
using General_Ledger_Service.Services.CsvDocuments;
|
||||
using General_Ledger_Service.Services.PdfDocuments;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace General_Ledger_Service.Services
|
||||
{
|
||||
public class ReportService(
|
||||
IAccountRepository accountRepository,
|
||||
IJournalEntryRepository journalEntryRepository,
|
||||
IBudgetRepository budgetRepository,
|
||||
ITaxRepository taxRepository,
|
||||
IFixedAssetRepository fixedAssetRepository,
|
||||
ISystemConfigRepository systemConfigRepository,
|
||||
ICurrencyRepository currencyRepository,
|
||||
IConfiguration configuration) : IReportService
|
||||
{
|
||||
private readonly IAccountRepository _accountRepository = accountRepository;
|
||||
private readonly IJournalEntryRepository _journalEntryRepository = journalEntryRepository;
|
||||
private readonly IBudgetRepository _budgetRepository = budgetRepository;
|
||||
private readonly ITaxRepository _taxRepository = taxRepository;
|
||||
private readonly IFixedAssetRepository _fixedAssetRepository = fixedAssetRepository;
|
||||
private readonly ISystemConfigRepository _systemConfigRepository = systemConfigRepository;
|
||||
private readonly ICurrencyRepository _currencyRepository = currencyRepository;
|
||||
private readonly IConfiguration _configuration = configuration;
|
||||
|
||||
public async Task<ApiResponse> GenerateAsync(ListReportsRequest request)
|
||||
{
|
||||
if (!Enum.TryParse<ReportType>(request.ReportType, ignoreCase: true, out var reportType))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid reportType '{request.ReportType}'. Must be one of: {string.Join(", ", Enum.GetNames<ReportType>())}.");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ReportOutputFormat>(request.OutputFormat, ignoreCase: true, out var outputFormat))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid outputFormat '{request.OutputFormat}'. Must be one of: {string.Join(", ", Enum.GetNames<ReportOutputFormat>())}.");
|
||||
}
|
||||
|
||||
object data = reportType switch
|
||||
{
|
||||
ReportType.GeneralLedger => await BuildGeneralLedgerAsync(request),
|
||||
ReportType.TrialBalance => await BuildTrialBalanceAsync(request),
|
||||
ReportType.BalanceSheet => await BuildBalanceSheetAsync(request),
|
||||
ReportType.ProfitAndLoss => await BuildProfitAndLossAsync(request),
|
||||
ReportType.CashFlow => await BuildCashFlowAsync(request),
|
||||
ReportType.BudgetVsActual => await BuildBudgetVsActualAsync(request),
|
||||
ReportType.TaxSummary => await BuildTaxSummaryAsync(request),
|
||||
_ => throw new BadRequestException($"Unsupported reportType '{reportType}'.")
|
||||
};
|
||||
|
||||
if (outputFormat == ReportOutputFormat.Pdf)
|
||||
{
|
||||
var pdfBytes = await RenderPdfAsync(reportType, request, data);
|
||||
return ResponseHelpers.Ok("Report generated successfully", new
|
||||
{
|
||||
fileName = $"{reportType}.pdf",
|
||||
contentType = "application/pdf",
|
||||
contentBase64 = Convert.ToBase64String(pdfBytes)
|
||||
});
|
||||
}
|
||||
|
||||
if (outputFormat == ReportOutputFormat.Csv)
|
||||
{
|
||||
var csvBytes = RenderCsv(reportType, data);
|
||||
return ResponseHelpers.Ok("Report generated successfully", new
|
||||
{
|
||||
fileName = $"{reportType}.csv",
|
||||
contentType = "text/csv",
|
||||
contentBase64 = Convert.ToBase64String(csvBytes)
|
||||
});
|
||||
}
|
||||
|
||||
return ResponseHelpers.Ok("Report generated successfully", data);
|
||||
}
|
||||
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 10 - one writer per report, same flattening
|
||||
// approach as the PDF layouts (a Section/Category column standing in for what the PDF
|
||||
// shows via headers/indentation), sharing CsvBuilder for row-building/escaping.
|
||||
private static byte[] RenderCsv(ReportType reportType, object data) => reportType switch
|
||||
{
|
||||
ReportType.TrialBalance => TrialBalanceCsvDocument.Generate((List<TrialBalanceRowDto>)data),
|
||||
ReportType.BalanceSheet => BalanceSheetCsvDocument.Generate((BalanceSheetReportDto)data),
|
||||
ReportType.ProfitAndLoss => ProfitAndLossCsvDocument.Generate((ProfitAndLossReportDto)data),
|
||||
ReportType.CashFlow => CashFlowCsvDocument.Generate((CashFlowReportDto)data),
|
||||
ReportType.GeneralLedger => GeneralLedgerCsvDocument.Generate((List<GeneralLedgerLineDto>)data),
|
||||
ReportType.BudgetVsActual => BudgetVsActualCsvDocument.Generate((List<BudgetVsActualRowDto>)data),
|
||||
ReportType.TaxSummary => IncomeTaxComputationCsvDocument.Generate((IncomeTaxComputationDto)data),
|
||||
_ => throw new BadRequestException($"CSV output for reportType '{reportType}' is not implemented yet.")
|
||||
};
|
||||
|
||||
// One bespoke QuestPDF IDocument per report (05_LKAS_Report_PDF_Templates.md), sharing
|
||||
// one header/footer component (ReportPageHeader) - replaces the old single generic
|
||||
// reflection-based renderer. `request`'s already-validated period/date fields are reused
|
||||
// as-is (the corresponding Build*Async call above already proved them non-null).
|
||||
private async Task<byte[]> RenderPdfAsync(ReportType reportType, ListReportsRequest request, object data)
|
||||
{
|
||||
var companyName = _configuration["Company:Name"] ?? "Company Name Not Configured";
|
||||
var currencyCode = await _currencyRepository.GetBaseCurrencyCodeAsync();
|
||||
|
||||
IDocument document = reportType switch
|
||||
{
|
||||
ReportType.TrialBalance => new TrialBalancePdfDocument(companyName, currencyCode, request.AsOfDate!.Value, (List<TrialBalanceRowDto>)data),
|
||||
ReportType.BudgetVsActual => new BudgetVsActualPdfDocument(companyName, currencyCode, request.BudgetId!.Value, (List<BudgetVsActualRowDto>)data),
|
||||
ReportType.TaxSummary => new IncomeTaxComputationPdfDocument(
|
||||
companyName,
|
||||
_configuration["Company:Address"] ?? "",
|
||||
_configuration["Company:Tin"] ?? "",
|
||||
_configuration["Company:Brn"] ?? "",
|
||||
currencyCode,
|
||||
(IncomeTaxComputationDto)data),
|
||||
ReportType.GeneralLedger => new GeneralLedgerPdfDocument(companyName, currencyCode, request.PeriodStart!.Value, request.PeriodEnd!.Value, (List<GeneralLedgerLineDto>)data),
|
||||
ReportType.BalanceSheet => new BalanceSheetPdfDocument(companyName, currencyCode, request.AsOfDate!.Value, (BalanceSheetReportDto)data),
|
||||
ReportType.ProfitAndLoss => new ProfitAndLossPdfDocument(companyName, currencyCode, (ProfitAndLossReportDto)data),
|
||||
ReportType.CashFlow => new CashFlowPdfDocument(companyName, currencyCode, (CashFlowReportDto)data),
|
||||
_ => throw new BadRequestException($"PDF output for reportType '{reportType}' is not implemented yet.")
|
||||
};
|
||||
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
private async Task<List<GeneralLedgerLineDto>> BuildGeneralLedgerAsync(ListReportsRequest request)
|
||||
{
|
||||
var periodStart = request.PeriodStart ?? throw new BadRequestException("periodStart is required for the GeneralLedger report");
|
||||
var periodEnd = request.PeriodEnd ?? throw new BadRequestException("periodEnd is required for the GeneralLedger report");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.AccountCode))
|
||||
{
|
||||
return await _journalEntryRepository.GetGeneralLedgerLinesForAllAccountsAsync(periodStart, periodEnd);
|
||||
}
|
||||
|
||||
var account = await _accountRepository.GetByCodeAsync(request.AccountCode) ?? throw new NotFoundException("Account not found");
|
||||
|
||||
return await _journalEntryRepository.GetGeneralLedgerLinesAsync(account.AccountId, periodStart, periodEnd);
|
||||
}
|
||||
|
||||
private async Task<List<TrialBalanceRowDto>> BuildTrialBalanceAsync(ListReportsRequest request)
|
||||
{
|
||||
var asOfDate = request.AsOfDate ?? throw new BadRequestException("asOfDate is required for the TrialBalance report");
|
||||
return await _accountRepository.GetTrialBalanceAsync(asOfDate);
|
||||
}
|
||||
|
||||
// Retrofit 2026-07-31 - Accounting_System_Design_LKAS.md Section 11.3. Restructured around
|
||||
// accounts.balance_sheet_classification (same recursive-tag pattern as cash_flow_category/
|
||||
// income_statement_section), replacing the old flat recursive-rollup array with a nested
|
||||
// Non-Current/Current Assets, Equity, Non-Current/Current Liabilities shape - a
|
||||
// user-supplied reference template (a classified LKAS 1 Statement of Financial Position)
|
||||
// needs that split and it isn't derivable from account_type_id alone. Untagged leaf
|
||||
// Asset/Liability rows land in their own "unclassified" bucket rather than being silently
|
||||
// dropped, same rule ProfitAndLoss's Unclassified section already established.
|
||||
private async Task<BalanceSheetReportDto> BuildBalanceSheetAsync(ListReportsRequest request)
|
||||
{
|
||||
var asOfDate = request.AsOfDate ?? throw new BadRequestException("asOfDate is required for the BalanceSheet report");
|
||||
var leafRows = await _accountRepository.GetBalanceSheetLeafRowsAsync(asOfDate);
|
||||
|
||||
static BalanceSheetSectionDto BuildSection(IEnumerable<BalanceSheetLeafRowDto> rows)
|
||||
{
|
||||
var lines = rows.Select(r => new BalanceSheetLineDto { AccountCode = r.AccountCode, AccountName = r.AccountName, Balance = r.Balance }).ToList();
|
||||
return new BalanceSheetSectionDto { Lines = lines, Total = lines.Sum(l => l.Balance) };
|
||||
}
|
||||
|
||||
var assetRows = leafRows.Where(r => r.AccountTypeId == 1).ToList();
|
||||
var liabilityRows = leafRows.Where(r => r.AccountTypeId == 2).ToList();
|
||||
var equityRows = leafRows.Where(r => r.AccountTypeId == 3).ToList();
|
||||
|
||||
var nonCurrentAssets = BuildSection(assetRows.Where(r => r.BalanceSheetClassification == "NON_CURRENT_ASSET"));
|
||||
var currentAssets = BuildSection(assetRows.Where(r => r.BalanceSheetClassification == "CURRENT_ASSET"));
|
||||
var unclassifiedAssets = BuildSection(assetRows.Where(r => r.BalanceSheetClassification is not ("NON_CURRENT_ASSET" or "CURRENT_ASSET")));
|
||||
|
||||
var nonCurrentLiabilities = BuildSection(liabilityRows.Where(r => r.BalanceSheetClassification == "NON_CURRENT_LIABILITY"));
|
||||
var currentLiabilities = BuildSection(liabilityRows.Where(r => r.BalanceSheetClassification == "CURRENT_LIABILITY"));
|
||||
var unclassifiedLiabilities = BuildSection(liabilityRows.Where(r => r.BalanceSheetClassification is not ("NON_CURRENT_LIABILITY" or "CURRENT_LIABILITY")));
|
||||
|
||||
var equity = BuildSection(equityRows);
|
||||
|
||||
// No closing-entry mechanism exists anywhere in this codebase - a fiscal year's
|
||||
// Income/Expense activity never gets moved into Equity by any journal entry. So
|
||||
// "Current Year Earnings" has to be cumulative since inception (DateOnly.MinValue),
|
||||
// not scoped to the fiscal year containing asOfDate, or the equation below breaks
|
||||
// again the moment a second fiscal year has any posted activity. Confirmed with the
|
||||
// project owner 2026-07-20 - see CLAUDE.md/03_Progress_Tracker.md. Folded into the
|
||||
// Equity section as its own line (retrofit 2026-07-31) rather than a separate
|
||||
// top-level field, since it's conceptually an Equity line item.
|
||||
var profitAndLoss = await BuildProfitAndLossReportAsync(DateOnly.MinValue, asOfDate);
|
||||
equity.Lines.Add(new BalanceSheetLineDto { AccountCode = "", AccountName = "Current Year Earnings", Balance = profitAndLoss.NetProfitForPeriod });
|
||||
equity.Total += profitAndLoss.NetProfitForPeriod;
|
||||
|
||||
var totalAssets = nonCurrentAssets.Total + currentAssets.Total + unclassifiedAssets.Total;
|
||||
var totalEquityAndLiabilities = equity.Total + nonCurrentLiabilities.Total + currentLiabilities.Total + unclassifiedLiabilities.Total;
|
||||
|
||||
return new BalanceSheetReportDto
|
||||
{
|
||||
AsOfDate = asOfDate,
|
||||
NonCurrentAssets = nonCurrentAssets,
|
||||
CurrentAssets = currentAssets,
|
||||
UnclassifiedAssets = unclassifiedAssets,
|
||||
TotalAssets = totalAssets,
|
||||
Equity = equity,
|
||||
NonCurrentLiabilities = nonCurrentLiabilities,
|
||||
CurrentLiabilities = currentLiabilities,
|
||||
UnclassifiedLiabilities = unclassifiedLiabilities,
|
||||
TotalEquityAndLiabilities = totalEquityAndLiabilities
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ProfitAndLossReportDto> BuildProfitAndLossAsync(ListReportsRequest request)
|
||||
{
|
||||
var periodStart = request.PeriodStart ?? throw new BadRequestException("periodStart is required for the ProfitAndLoss report");
|
||||
var periodEnd = request.PeriodEnd ?? throw new BadRequestException("periodEnd is required for the ProfitAndLoss report");
|
||||
return await BuildProfitAndLossReportAsync(periodStart, periodEnd);
|
||||
}
|
||||
|
||||
// Retrofit 2026-07-22 - Accounting_System_Design_LKAS.md Section 11.4. Shared by the
|
||||
// ProfitAndLoss report itself, BalanceSheet's "Current Year Earnings" row, and CashFlow's
|
||||
// profitForPeriod - all three just need the resulting NetProfitForPeriod figure, computed
|
||||
// once here from the raw leaf rows so the Expense-side sign convention lives in exactly
|
||||
// one place.
|
||||
private async Task<ProfitAndLossReportDto> BuildProfitAndLossReportAsync(DateOnly periodStart, DateOnly periodEnd)
|
||||
{
|
||||
var leafRows = await _accountRepository.GetProfitAndLossLeafRowsAsync(periodStart, periodEnd);
|
||||
|
||||
// Raw Amount (credit - debit) is already positive-normal for Income (account_type_id
|
||||
// 4) but negative-normal for Expense (account_type_id 5, debit-normal) - flip Expense
|
||||
// rows to positive so every expense-side section/total reads as a positive figure,
|
||||
// per Accounting_System_Design_LKAS.md Section 11.7's explicit sign-convention note
|
||||
// (the same net_movement formula is shared between the P&L and Income Tax Computation
|
||||
// queries). Unclassified rows keep their raw (already net-correct) sign so summing
|
||||
// them straight into NetProfitForPeriod doesn't need a second special case.
|
||||
ProfitAndLossSectionDto BuildSection(string section, bool isExpense) =>
|
||||
BuildSectionFromRows(leafRows.Where(r => r.IncomeStatementSection == section), isExpense);
|
||||
|
||||
var sales = BuildSection("SALES", isExpense: false);
|
||||
var costOfSales = BuildSection("COST_OF_SALES", isExpense: true);
|
||||
var grossProfit = sales.Total - costOfSales.Total;
|
||||
|
||||
var otherIncome = BuildSection("OTHER_INCOME", isExpense: false);
|
||||
var distributionExpenses = BuildSection("DISTRIBUTION_EXPENSE", isExpense: true);
|
||||
var administrationExpenses = BuildSection("ADMINISTRATION_EXPENSE", isExpense: true);
|
||||
var otherExpenses = BuildSection("OTHER_EXPENSE", isExpense: true);
|
||||
var financialExpenses = BuildSection("FINANCIAL_EXPENSE", isExpense: true);
|
||||
var totalExpenses = distributionExpenses.Total + administrationExpenses.Total + otherExpenses.Total + financialExpenses.Total;
|
||||
|
||||
var unclassifiedRows = leafRows.Where(r => r.IncomeStatementSection is null).ToList();
|
||||
var unclassified = new ProfitAndLossSectionDto
|
||||
{
|
||||
Lines = [.. unclassifiedRows.Select(r => new ProfitAndLossLineDto { AccountCode = r.AccountCode, AccountName = r.AccountName, Amount = r.Amount })],
|
||||
Total = unclassifiedRows.Sum(r => r.Amount)
|
||||
};
|
||||
|
||||
var netProfitForPeriod = grossProfit + otherIncome.Total - totalExpenses + unclassified.Total;
|
||||
|
||||
return new ProfitAndLossReportDto
|
||||
{
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
Sales = sales,
|
||||
CostOfSales = costOfSales,
|
||||
GrossProfit = grossProfit,
|
||||
OtherIncome = otherIncome,
|
||||
DistributionExpenses = distributionExpenses,
|
||||
AdministrationExpenses = administrationExpenses,
|
||||
OtherExpenses = otherExpenses,
|
||||
FinancialExpenses = financialExpenses,
|
||||
Unclassified = unclassified,
|
||||
NetProfitForPeriod = netProfitForPeriod
|
||||
};
|
||||
}
|
||||
|
||||
private static ProfitAndLossSectionDto BuildSectionFromRows(IEnumerable<ProfitAndLossLeafRowDto> rows, bool isExpense)
|
||||
{
|
||||
var lines = rows.Select(r => new ProfitAndLossLineDto
|
||||
{
|
||||
AccountCode = r.AccountCode,
|
||||
AccountName = r.AccountName,
|
||||
Amount = isExpense ? -r.Amount : r.Amount
|
||||
}).ToList();
|
||||
|
||||
return new ProfitAndLossSectionDto { Lines = lines, Total = lines.Sum(l => l.Amount) };
|
||||
}
|
||||
|
||||
// Full LKAS 7 indirect-method cash flow - Accounting_System_Design_LKAS.md Section 11.5.
|
||||
private async Task<CashFlowReportDto> BuildCashFlowAsync(ListReportsRequest request)
|
||||
{
|
||||
var periodStart = request.PeriodStart ?? throw new BadRequestException("periodStart is required for the CashFlow report");
|
||||
var periodEnd = request.PeriodEnd ?? throw new BadRequestException("periodEnd is required for the CashFlow report");
|
||||
|
||||
var cashAccountRootConfig = await _systemConfigRepository.GetByKeyAsync("CASH_ACCOUNT_ROOT_ID");
|
||||
if (cashAccountRootConfig is null || !long.TryParse(cashAccountRootConfig.ConfigValue, out var cashAccountRootId))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
"CASH_ACCOUNT_ROOT_ID is not configured yet. Set it via PUT /api/v1/system-config/CASH_ACCOUNT_ROOT_ID to the account_id of your Cash and Cash Equivalents COA header before running the CashFlow report.");
|
||||
}
|
||||
|
||||
var profitAndLoss = await BuildProfitAndLossReportAsync(periodStart, periodEnd);
|
||||
var profitForPeriod = profitAndLoss.NetProfitForPeriod;
|
||||
|
||||
var depreciationAddBack = await _fixedAssetRepository.GetDepreciationAddBackAsync(periodStart, periodEnd);
|
||||
|
||||
var nonCashAdjustments = new List<CashFlowNonCashAdjustmentDto>();
|
||||
if (depreciationAddBack != 0)
|
||||
{
|
||||
nonCashAdjustments.Add(new CashFlowNonCashAdjustmentDto { Description = "Depreciation", Amount = depreciationAddBack });
|
||||
}
|
||||
|
||||
var workingCapitalChanges = await _accountRepository.GetCashFlowWorkingCapitalChangesAsync(periodStart, periodEnd, cashAccountRootId);
|
||||
var netCashFromOperating = profitForPeriod + depreciationAddBack + workingCapitalChanges.Sum(c => c.ChangeAmount);
|
||||
|
||||
var investingLines = await _accountRepository.GetCashFlowCategoryLinesAsync("INVESTING", periodStart, periodEnd, cashAccountRootId);
|
||||
var netInvesting = investingLines.Sum(l => l.Amount);
|
||||
|
||||
var financingLines = await _accountRepository.GetCashFlowCategoryLinesAsync("FINANCING", periodStart, periodEnd, cashAccountRootId);
|
||||
var netFinancing = financingLines.Sum(l => l.Amount);
|
||||
|
||||
var netIncreaseDecrease = netCashFromOperating + netInvesting + netFinancing;
|
||||
|
||||
var openingCashBalance = await _accountRepository.GetCashBalanceAsync(periodStart.AddDays(-1), cashAccountRootId);
|
||||
var closingCashBalance = await _accountRepository.GetCashBalanceAsync(periodEnd, cashAccountRootId);
|
||||
|
||||
return new CashFlowReportDto
|
||||
{
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
OperatingActivities = new CashFlowOperatingActivitiesDto
|
||||
{
|
||||
ProfitForPeriod = profitForPeriod,
|
||||
NonCashAdjustments = nonCashAdjustments,
|
||||
WorkingCapitalChanges = workingCapitalChanges,
|
||||
NetCashFromOperatingActivities = netCashFromOperating
|
||||
},
|
||||
InvestingActivities = new CashFlowInvestingActivitiesDto
|
||||
{
|
||||
Lines = investingLines,
|
||||
NetCashFromInvestingActivities = netInvesting
|
||||
},
|
||||
FinancingActivities = new CashFlowFinancingActivitiesDto
|
||||
{
|
||||
Lines = financingLines,
|
||||
NetCashFromFinancingActivities = netFinancing
|
||||
},
|
||||
NetIncreaseDecreaseInCash = netIncreaseDecrease,
|
||||
OpeningCashBalance = openingCashBalance,
|
||||
ClosingCashBalance = closingCashBalance
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<BudgetVsActualRowDto>> BuildBudgetVsActualAsync(ListReportsRequest request)
|
||||
{
|
||||
var budgetId = request.BudgetId ?? throw new BadRequestException("budgetId is required for the BudgetVsActual report");
|
||||
_ = await _budgetRepository.GetByIdAsync(budgetId) ?? throw new NotFoundException("Budget not found");
|
||||
return await _budgetRepository.GetBudgetVsActualAsync(budgetId);
|
||||
}
|
||||
|
||||
// Retrofit 2026-07-22 - Accounting_System_Design_LKAS.md Section 11.7. Full corporate
|
||||
// Income Tax Computation (current tax only, no deferred tax), replacing the 2026-07-21
|
||||
// VAT/WHT/NBT per-tax_code summary wholesale under the same TaxSummary reportType name.
|
||||
private async Task<IncomeTaxComputationDto> BuildTaxSummaryAsync(ListReportsRequest request)
|
||||
{
|
||||
var periodStart = request.PeriodStart ?? throw new BadRequestException("periodStart is required for the TaxSummary report");
|
||||
var periodEnd = request.PeriodEnd ?? throw new BadRequestException("periodEnd is required for the TaxSummary report");
|
||||
|
||||
var profitAndLoss = await BuildProfitAndLossReportAsync(periodStart, periodEnd);
|
||||
var profitBeforeTax = profitAndLoss.NetProfitForPeriod;
|
||||
|
||||
var nonDeductibleExpenses = await _taxRepository.GetNonDeductibleExpensesAsync(periodStart, periodEnd);
|
||||
var allowableDeductions = request.AllowableDeductions ?? 0;
|
||||
var adjustedBusinessProfit = profitBeforeTax + nonDeductibleExpenses - allowableDeductions;
|
||||
|
||||
var otherTaxableIncome = request.OtherTaxableIncome ?? 0;
|
||||
var assessableIncome = adjustedBusinessProfit + otherTaxableIncome;
|
||||
|
||||
var qualifyingPaymentsReliefs = request.QualifyingPaymentsReliefs ?? await GetConfigDecimalAsync("STATUTORY_RELIEF_AMOUNT", 0);
|
||||
var taxableIncome = assessableIncome - qualifyingPaymentsReliefs;
|
||||
|
||||
var taxRatePercent = request.TaxRateOverride ?? await GetConfigDecimalAsync("CORPORATE_INCOME_TAX_RATE", 30);
|
||||
var corporateIncomeTax = taxableIncome * taxRatePercent / 100;
|
||||
|
||||
var surchargeAmount = request.SurchargeAmount ?? 0;
|
||||
var grossTaxLiability = corporateIncomeTax + surchargeAmount;
|
||||
|
||||
// Unlike CASH_ACCOUNT_ROOT_ID, an unset credit account isn't a hard failure - a
|
||||
// company that simply has no APIT/WHT/quarterly-payment activity yet (or hasn't
|
||||
// configured the account) just gets a 0 credit, not a blocked report.
|
||||
var apitCredit = await GetConfiguredAccountCreditAsync("APIT_CREDIT_ACCOUNT_ID", periodStart, periodEnd);
|
||||
var whtCredit = await GetConfiguredAccountCreditAsync("WHT_CREDIT_ACCOUNT_ID", periodStart, periodEnd);
|
||||
var quarterlyTaxPayments = await GetConfiguredAccountCreditAsync("QUARTERLY_TAX_PAYMENT_ACCOUNT_ID", periodStart, periodEnd);
|
||||
|
||||
var balanceTaxPayable = grossTaxLiability - apitCredit - whtCredit - quarterlyTaxPayments;
|
||||
|
||||
return new IncomeTaxComputationDto
|
||||
{
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
ProfitBeforeTax = profitBeforeTax,
|
||||
NonDeductibleExpenses = nonDeductibleExpenses,
|
||||
AllowableDeductions = allowableDeductions,
|
||||
AdjustedBusinessProfit = adjustedBusinessProfit,
|
||||
OtherTaxableIncome = otherTaxableIncome,
|
||||
AssessableIncome = assessableIncome,
|
||||
QualifyingPaymentsReliefs = qualifyingPaymentsReliefs,
|
||||
TaxableIncome = taxableIncome,
|
||||
TaxRatePercent = taxRatePercent,
|
||||
CorporateIncomeTax = corporateIncomeTax,
|
||||
SurchargeAmount = surchargeAmount,
|
||||
GrossTaxLiability = grossTaxLiability,
|
||||
ApitCredit = apitCredit,
|
||||
WhtCredit = whtCredit,
|
||||
QuarterlyTaxPayments = quarterlyTaxPayments,
|
||||
BalanceTaxPayable = balanceTaxPayable
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<decimal> GetConfigDecimalAsync(string key, decimal fallback)
|
||||
{
|
||||
var config = await _systemConfigRepository.GetByKeyAsync(key);
|
||||
return config is not null && decimal.TryParse(config.ConfigValue, out var value) ? value : fallback;
|
||||
}
|
||||
|
||||
private async Task<decimal> GetConfiguredAccountCreditAsync(string configKey, DateOnly periodStart, DateOnly periodEnd)
|
||||
{
|
||||
var config = await _systemConfigRepository.GetByKeyAsync(configKey);
|
||||
if (config is null || !long.TryParse(config.ConfigValue, out var accountId))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await _taxRepository.GetAccountCreditActivityAsync(accountId, periodStart, periodEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.DTOs.SystemConfig;
|
||||
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 SystemConfigService(
|
||||
ISystemConfigRepository systemConfigRepository,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
IUnitOfWork unitOfWork) : ISystemConfigService
|
||||
{
|
||||
// Per-key audit classification (CLAUDE.md Rule 10 / Document 1 Section 4.3) - a
|
||||
// future purely-cosmetic key must not silently inherit Ledger, so anything not
|
||||
// listed here defaults to Administrative rather than Ledger.
|
||||
private static readonly Dictionary<string, AuditCategory> KeyCategories = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["COA_MAX_DEPTH"] = AuditCategory.Ledger,
|
||||
["COST_CENTER_MAX_DEPTH"] = AuditCategory.Ledger,
|
||||
["COA_ENFORCE_MAX_DEPTH"] = AuditCategory.Ledger,
|
||||
["CASH_ACCOUNT_ROOT_ID"] = AuditCategory.Ledger,
|
||||
["CORPORATE_INCOME_TAX_RATE"] = AuditCategory.Ledger,
|
||||
["STATUTORY_RELIEF_AMOUNT"] = AuditCategory.Ledger,
|
||||
["APIT_CREDIT_ACCOUNT_ID"] = AuditCategory.Ledger,
|
||||
["WHT_CREDIT_ACCOUNT_ID"] = AuditCategory.Ledger,
|
||||
["QUARTERLY_TAX_PAYMENT_ACCOUNT_ID"] = AuditCategory.Ledger
|
||||
};
|
||||
|
||||
private readonly ISystemConfigRepository _systemConfigRepository = systemConfigRepository;
|
||||
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork;
|
||||
|
||||
public async Task<ApiResponse> UpdateAsync(string key, UpdateSystemConfigRequest request)
|
||||
{
|
||||
var config = await _systemConfigRepository.GetByKeyAsync(key)
|
||||
?? throw new NotFoundException("System config key not found");
|
||||
|
||||
if (!config.IsEditable || config.IsLocked)
|
||||
{
|
||||
throw new BadRequestException($"System config key '{key}' cannot be edited");
|
||||
}
|
||||
|
||||
config.ConfigValue = request.ConfigValue;
|
||||
config.IsLocked = true;
|
||||
|
||||
var category = KeyCategories.TryGetValue(key, out var mappedCategory)
|
||||
? mappedCategory
|
||||
: AuditCategory.Administrative;
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = category,
|
||||
EntityName = "SystemConfig",
|
||||
EntityId = key,
|
||||
Action = AuditAction.Update,
|
||||
Description = $"System config {key} updated",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Ok("System config updated successfully", config);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> ListAsync()
|
||||
{
|
||||
var configs = await _systemConfigRepository.GetAllAsync();
|
||||
return ResponseHelpers.Ok("System config retrieved successfully", configs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using General_Ledger_Service.DTOs.Common;
|
||||
using General_Ledger_Service.DTOs.Tax;
|
||||
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 TaxService(
|
||||
ITaxRepository taxRepository,
|
||||
IAccountRepository accountRepository,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
IUnitOfWork unitOfWork) : ITaxService
|
||||
{
|
||||
private readonly ITaxRepository _taxRepository = taxRepository;
|
||||
private readonly IAccountRepository _accountRepository = accountRepository;
|
||||
private readonly IAuditLogRepository _auditLogRepository = auditLogRepository;
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork;
|
||||
|
||||
public async Task<ApiResponse> CreateTaxCodeAsync(CreateTaxCodeRequest request)
|
||||
{
|
||||
if (request.EffectiveFrom == default)
|
||||
{
|
||||
throw new BadRequestException("effectiveFrom is required");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<TaxCalculationBasis>(request.CalculationBasis, ignoreCase: true, out var calculationBasis))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid calculationBasis '{request.CalculationBasis}'. Must be one of: {string.Join(", ", Enum.GetNames<TaxCalculationBasis>())}.");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<TaxAppliesTo>(request.AppliesTo, ignoreCase: true, out var appliesTo))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Invalid appliesTo '{request.AppliesTo}'. Must be one of: {string.Join(", ", Enum.GetNames<TaxAppliesTo>())}.");
|
||||
}
|
||||
|
||||
var taxType = await _taxRepository.GetTaxTypeByCodeAsync(request.TaxTypeCode)
|
||||
?? throw new NotFoundException("Tax type not found");
|
||||
|
||||
long? outputTaxAccountId = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.OutputTaxAccountCode))
|
||||
{
|
||||
var outputAccount = await _accountRepository.GetByCodeAsync(request.OutputTaxAccountCode)
|
||||
?? throw new NotFoundException("Output tax account not found");
|
||||
outputTaxAccountId = outputAccount.AccountId;
|
||||
}
|
||||
|
||||
long? inputTaxAccountId = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.InputTaxAccountCode))
|
||||
{
|
||||
var inputAccount = await _accountRepository.GetByCodeAsync(request.InputTaxAccountCode)
|
||||
?? throw new NotFoundException("Input tax account not found");
|
||||
inputTaxAccountId = inputAccount.AccountId;
|
||||
}
|
||||
|
||||
var code = await GenerateTaxCodeAsync(taxType);
|
||||
|
||||
var taxCode = new TaxCode
|
||||
{
|
||||
TaxTypeId = taxType.TaxTypeId,
|
||||
Code = code,
|
||||
Name = request.Name,
|
||||
RatePercent = request.RatePercent,
|
||||
CalculationBasis = calculationBasis,
|
||||
AppliesTo = appliesTo,
|
||||
OutputTaxAccountId = outputTaxAccountId,
|
||||
InputTaxAccountId = inputTaxAccountId,
|
||||
EffectiveFrom = request.EffectiveFrom,
|
||||
EffectiveTo = request.EffectiveTo,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await _unitOfWork.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _taxRepository.AddTaxCodeAsync(taxCode);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _auditLogRepository.AddAsync(new AuditLog
|
||||
{
|
||||
Category = AuditCategory.Ledger,
|
||||
EntityName = "TaxCode",
|
||||
EntityId = taxCode.TaxCodeId.ToString(),
|
||||
Action = AuditAction.Create,
|
||||
Description = $"Tax code {taxCode.Code} created",
|
||||
PerformedBy = null
|
||||
});
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return ResponseHelpers.Created("Tax code created successfully", taxCode);
|
||||
}
|
||||
|
||||
private async Task<string> GenerateTaxCodeAsync(TaxType taxType)
|
||||
{
|
||||
// Probes candidates directly rather than trusting a count (same fix as
|
||||
// AccountService.GenerateAccountCodeAsync) so a pre-existing, manually-coded tax code
|
||||
// can never collide with a generated one.
|
||||
for (var seq = 1; seq <= 999; seq++)
|
||||
{
|
||||
var code = $"{taxType.Code}-{seq:D3}";
|
||||
if (await _taxRepository.GetByCodeAsync(code) is null)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException("Maximum number of tax codes for this tax type has been reached.");
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> CalculateTaxAsync(CalculateTaxRequest request)
|
||||
{
|
||||
var taxCode = await _taxRepository.GetEffectiveTaxCodeByCodeAsync(request.TaxCode, request.TxnDate)
|
||||
?? throw new NotFoundException("Tax code not found or not effective for the given date");
|
||||
|
||||
var taxAmount = taxCode.CalculationBasis switch
|
||||
{
|
||||
TaxCalculationBasis.Exclusive => request.Amount * (taxCode.RatePercent / 100),
|
||||
TaxCalculationBasis.Inclusive => request.Amount - (request.Amount / (1 + (taxCode.RatePercent / 100))),
|
||||
_ => throw new BadRequestException($"Unsupported calculationBasis '{taxCode.CalculationBasis}'.")
|
||||
};
|
||||
|
||||
return ResponseHelpers.Ok("Tax calculated successfully", new CalculateTaxResponse
|
||||
{
|
||||
TaxCodeId = taxCode.TaxCodeId,
|
||||
Code = taxCode.Code,
|
||||
RatePercent = taxCode.RatePercent,
|
||||
LineTotal = request.Amount,
|
||||
TaxAmount = taxAmount
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> ListTaxTypesAsync()
|
||||
{
|
||||
var taxTypes = await _taxRepository.GetAllTaxTypesAsync();
|
||||
return ResponseHelpers.Ok("Tax types retrieved successfully", taxTypes);
|
||||
}
|
||||
|
||||
public async Task<ApiResponse> ListTaxCodesAsync()
|
||||
{
|
||||
var taxCodes = await _taxRepository.GetAllTaxCodesAsync();
|
||||
return ResponseHelpers.Ok("Tax codes retrieved successfully", taxCodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user