Distribution_GL Initialization

This commit is contained in:
2026-10-20 13:07:07 +05:30
commit 3f16e0cb44
194 changed files with 35659 additions and 0 deletions
+67
View File
@@ -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.");
}
}
}
+225
View File
@@ -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.");
}
}
}
+63
View File
@@ -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 });
}
}
+574
View File
@@ -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;
}
}
}
}
+132
View File
@@ -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);
}
}
}
+522
View File
@@ -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 });
}
}
}
+129
View File
@@ -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();
}
}
}
+46
View File
@@ -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();
}
}
}
+17
View File
@@ -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);
}
}
}
+30
View File
@@ -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 });
}
}
}
+248
View File
@@ -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;
}
}
}
+341
View File
@@ -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;
}
}
+276
View File
@@ -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);
}
}
}
+22
View File
@@ -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();
});
}
}
}
+67
View File
@@ -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();
});
});
}
}
}
+171
View File
@@ -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);
}
}
}
+437
View File
@@ -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);
}
}
}
+87
View File
@@ -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);
}
}
}
+157
View File
@@ -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);
}
}
}