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
+316
View File
@@ -0,0 +1,316 @@
using General_Ledger_Service.DTOs.Accounts;
using General_Ledger_Service.DTOs.Reports;
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class AccountRepository(AppDBContext context) : IAccountRepository
{
private readonly AppDBContext _context = context;
public Task<Account?> GetByIdAsync(long accountId) =>
RepositoryHelpers.GetByIdAsync(_context.Accounts, accountId);
public async Task<Account?> GetByCodeAsync(string accountCode) =>
await _context.Accounts.FirstOrDefaultAsync(a => a.AccountCode == accountCode);
// Used by BankService's auto-created "Bank"/"Cash" GL hierarchy (2026-07-31) to find an
// existing system header/type node by its fixed name before creating a new one -
// accountName is always a fixed literal here ("Bank", "Cash", a CashAccountType.Name),
// never end-user free text, so a name collision would only ever come from the same
// auto-creation logic itself, not an unrelated manually-created account.
public async Task<Account?> GetByNameAndParentAsync(string accountName, long? parentAccountId) =>
await _context.Accounts.FirstOrDefaultAsync(a => a.AccountName == accountName && a.ParentAccountId == parentAccountId);
public async Task<int> GetAncestorDepthAsync(long parentAccountId) =>
RecursiveTreeQueries.GetAncestorDepth(await LoadTreeRowsAsync(), parentAccountId);
public async Task<string?> GetAncestorBreadcrumbAsync(long accountId) =>
RecursiveTreeQueries.GetAncestorBreadcrumb(await LoadTreeRowsAsync(), accountId);
public async Task<List<AccountTreeNode>> GetDescendantTreeAsync(long accountId)
{
var rows = RecursiveTreeQueries.GetDescendantTree(await LoadTreeRowsAsync(), accountId);
return [.. rows.Select(r => new AccountTreeNode
{
AccountId = r.Id,
AccountCode = r.Code,
AccountName = r.Name,
ParentAccountId = r.ParentId,
Depth = r.Depth
})];
}
public async Task<bool> WouldCreateCycleAsync(long accountId, long newParentId) =>
RecursiveTreeQueries.WouldCreateCycle(await LoadTreeRowsAsync(), accountId, newParentId);
public async Task<(List<Account> Items, int TotalCount)> SearchAsync(short? accountTypeId, bool? isActive, bool? isPostable, int? page, int? pageSize)
{
var query = _context.Accounts.AsQueryable();
if (accountTypeId.HasValue)
{
query = query.Where(a => a.AccountTypeId == accountTypeId.Value);
}
if (isActive.HasValue)
{
query = query.Where(a => a.IsActive == isActive.Value);
}
if (isPostable.HasValue)
{
query = query.Where(a => a.IsPostable == isPostable.Value);
}
var totalCount = await query.CountAsync();
var items = await RepositoryHelpers.ApplyPaging(query.OrderBy(a => a.AccountCode), page, pageSize)
.ToListAsync();
return (items, totalCount);
}
public Task<Account> AddAsync(Account account) =>
RepositoryHelpers.AddAsync(_context.Accounts, account);
// Retrofit 2026-07-22 - flat list, no depth/indentation (05_LKAS_Report_PDF_Templates.md
// Section 2): a plain postable-leaf-account listing needs no recursive tree at all.
// Fix 2026-10-20: each row shows the account's NET balance in exactly one column (debit
// balance -> Debit, credit balance -> Credit, the other stays 0 and renders blank), and an
// account whose debits and credits cancel out (net zero) is left out entirely - per
// 05_LKAS_Report_PDF_Templates.md Section 2. Previously this returned the gross debit and
// credit totals side by side, so any account with movement on both sides (or an entry and
// its reversal) showed equal/duplicated figures in both columns.
public async Task<List<TrialBalanceRowDto>> GetTrialBalanceAsync(DateOnly asOfDate)
{
var totals = await GetPostedTotalsAsync(null, asOfDate);
var postableAccounts = await _context.Accounts.AsNoTracking().Where(a => a.IsPostable).ToListAsync();
return [.. postableAccounts
.Where(a => totals.TryGetValue(a.AccountId, out var t) && t.Debit != t.Credit)
.OrderBy(a => a.AccountCode, StringComparer.Ordinal)
.Select(a =>
{
var net = totals[a.AccountId].Debit - totals[a.AccountId].Credit;
return new TrialBalanceRowDto
{
AccountCode = a.AccountCode,
AccountName = a.AccountName,
Debit = net > 0 ? net : 0,
Credit = net < 0 ? -net : 0
};
})];
}
// Retrofit 2026-07-31 - Accounting_System_Design_LKAS.md Section 11.3. Leaf/postable rows
// only, tagged with balance_sheet_classification inherited down the tree (same
// recursive-tag pattern as cash_flow_category/income_statement_section); ReportService
// groups these into the nested BalanceSheetReportDto (Non-Current/Current Assets,
// Equity, Non-Current/Current Liabilities) and computes section/grand totals. Balance is
// already sign-adjusted per account type: Asset is debit-normal (debit - credit);
// Liability/Equity are credit-normal, so they use the opposite sign (credit - debit) to
// show as conventional positive balances too - the same flip the P&L applies.
public async Task<List<BalanceSheetLeafRowDto>> GetBalanceSheetLeafRowsAsync(DateOnly asOfDate)
{
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
var roots = accounts.Where(a => a.ParentAccountId == null && (a.AccountTypeId == 1 || a.AccountTypeId == 2 || a.AccountTypeId == 3));
var classification = InheritTag(accounts, roots, a => a.BalanceSheetClassification);
var totals = await GetPostedTotalsAsync(null, asOfDate);
return [.. accounts
.Where(a => a.IsPostable && classification.ContainsKey(a.AccountId) && totals.ContainsKey(a.AccountId))
.Select(a => new BalanceSheetLeafRowDto
{
AccountCode = a.AccountCode,
AccountName = a.AccountName,
AccountTypeId = a.AccountTypeId,
BalanceSheetClassification = classification[a.AccountId],
Balance = SignedBalance(a.AccountTypeId, totals[a.AccountId])
})
.Where(r => r.Balance != 0)
.OrderBy(r => r.AccountCode, StringComparer.Ordinal)];
}
// Retrofit 2026-07-22 - Accounting_System_Design_LKAS.md Section 11.4. Leaf/postable rows
// only, tagged with income_statement_section inherited down the tree (same recursive-tag
// pattern as cash_flow_category); ReportService groups these into the nested
// ProfitAndLossReportDto and applies the Expense-side sign flip (Amount here is the raw,
// unflipped credit - debit - negative for a normal debit-balance Expense account).
public async Task<List<ProfitAndLossLeafRowDto>> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd)
{
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
var roots = accounts.Where(a => a.ParentAccountId == null && (a.AccountTypeId == 4 || a.AccountTypeId == 5));
var section = InheritTag(accounts, roots, a => a.IncomeStatementSection);
var totals = await GetPostedTotalsAsync(periodStart, periodEnd);
return [.. accounts
.Where(a => a.IsPostable && section.ContainsKey(a.AccountId) && totals.ContainsKey(a.AccountId))
.Select(a => new ProfitAndLossLeafRowDto
{
AccountCode = a.AccountCode,
AccountName = a.AccountName,
AccountTypeId = a.AccountTypeId,
IncomeStatementSection = section[a.AccountId],
Amount = totals[a.AccountId].Credit - totals[a.AccountId].Debit
})
.Where(r => r.Amount != 0)
.OrderBy(r => r.AccountCode, StringComparer.Ordinal)];
}
// Accounting_System_Design_LKAS.md Section 11.5 - "cash-like" accounts are the recursive
// descendants of the configured CASH_ACCOUNT_ROOT_ID system_config value (resolved and
// validated by ReportService before this is ever called - a plain long here, so an
// unset/invalid config fails with a clear error before reaching any query).
public async Task<List<CashFlowWorkingCapitalChangeDto>> GetCashFlowWorkingCapitalChangesAsync(DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId)
{
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
var cashAccountIds = RecursiveTreeQueries.GetDescendantIds(ToTreeRows(accounts), cashAccountRootId);
var category = InheritTag(accounts, accounts.Where(a => a.ParentAccountId == null), a => a.CashFlowCategory);
var opening = await GetPostedTotalsAsync(null, periodStart.AddDays(-1));
var closing = await GetPostedTotalsAsync(null, periodEnd);
var rows = new List<CashFlowWorkingCapitalChangeDto>();
foreach (var a in accounts.Where(a => a.IsPostable
&& !cashAccountIds.Contains(a.AccountId)
&& category.GetValueOrDefault(a.AccountId) == "OPERATING"))
{
var openingBalance = opening.TryGetValue(a.AccountId, out var o) ? SignedBalance(a.AccountTypeId, o) : 0m;
var closingBalance = closing.TryGetValue(a.AccountId, out var c) ? SignedBalance(a.AccountTypeId, c) : 0m;
var change = closingBalance - openingBalance;
if (change == 0)
{
continue;
}
rows.Add(new CashFlowWorkingCapitalChangeDto
{
AccountCode = a.AccountCode,
AccountName = a.AccountName,
Direction = change >= 0 ? "Increase" : "Decrease",
ChangeAmount = a.AccountTypeId == 1 ? -change : change
});
}
return [.. rows.OrderBy(r => r.AccountCode, StringComparer.Ordinal)];
}
public async Task<List<CashFlowLineDto>> GetCashFlowCategoryLinesAsync(string category, DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId)
{
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
var cashAccountIds = RecursiveTreeQueries.GetDescendantIds(ToTreeRows(accounts), cashAccountRootId);
var inherited = InheritTag(accounts, accounts.Where(a => a.ParentAccountId == null), a => a.CashFlowCategory);
var totals = await GetPostedTotalsAsync(periodStart, periodEnd);
return [.. accounts
.Where(a => a.IsPostable
&& !cashAccountIds.Contains(a.AccountId)
&& inherited.GetValueOrDefault(a.AccountId) == category
&& totals.ContainsKey(a.AccountId))
.Select(a => new CashFlowLineDto
{
AccountCode = a.AccountCode,
AccountName = a.AccountName,
Amount = totals[a.AccountId].Credit - totals[a.AccountId].Debit
})
.OrderBy(r => r.AccountCode, StringComparer.Ordinal)];
}
public async Task<decimal> GetCashBalanceAsync(DateOnly asOfDate, long cashAccountRootId)
{
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
var cashAccountIds = RecursiveTreeQueries.GetDescendantIds(ToTreeRows(accounts), cashAccountRootId);
var totals = await GetPostedTotalsAsync(null, asOfDate);
return totals
.Where(t => cashAccountIds.Contains(t.Key))
.Sum(t => t.Value.Debit - t.Value.Credit);
}
private async Task<List<RecursiveTreeQueries.TreeRow>> LoadTreeRowsAsync() =>
await _context.Accounts.AsNoTracking()
.Select(a => new RecursiveTreeQueries.TreeRow
{
Id = a.AccountId,
Code = a.AccountCode,
Name = a.AccountName,
ParentId = a.ParentAccountId
})
.ToListAsync();
private static List<RecursiveTreeQueries.TreeRow> ToTreeRows(IEnumerable<Account> accounts) =>
[.. accounts.Select(a => new RecursiveTreeQueries.TreeRow
{
Id = a.AccountId,
Code = a.AccountCode,
Name = a.AccountName,
ParentId = a.ParentAccountId
})];
// Per-account base debit/credit totals over posted journal entries, optionally bounded
// by entry date (inclusive on both ends). The sums run in the database; accounts with no
// matching lines are simply absent from the result.
private async Task<Dictionary<long, (decimal Debit, decimal Credit)>> GetPostedTotalsAsync(DateOnly? from, DateOnly? to)
{
var lines = _context.JournalEntryLines.Where(l => l.Journal.IsPosted);
if (from.HasValue)
{
lines = lines.Where(l => l.Journal.EntryDate >= from.Value);
}
if (to.HasValue)
{
lines = lines.Where(l => l.Journal.EntryDate <= to.Value);
}
var totals = await lines
.GroupBy(l => l.AccountId)
.Select(g => new { AccountId = g.Key, Debit = g.Sum(l => l.BaseDebit), Credit = g.Sum(l => l.BaseCredit) })
.ToListAsync();
return totals.ToDictionary(t => t.AccountId, t => (t.Debit, t.Credit));
}
// Asset (type 1) is debit-normal; every other type is credit-normal.
private static decimal SignedBalance(short accountTypeId, (decimal Debit, decimal Credit) totals) =>
accountTypeId == 1 ? totals.Debit - totals.Credit : totals.Credit - totals.Debit;
// Walks the tree from the given roots, giving each node its own tag or, if it has none,
// its parent's inherited tag. Only nodes reachable from the roots appear in the result,
// so a root filter (e.g. types 1-3 only) also scopes which accounts are included.
private static Dictionary<long, string?> InheritTag(List<Account> accounts, IEnumerable<Account> roots, Func<Account, string?> tag)
{
var childrenByParent = accounts
.Where(a => a.ParentAccountId.HasValue)
.ToLookup(a => a.ParentAccountId!.Value);
var result = new Dictionary<long, string?>();
var stack = new Stack<(Account Node, string? Inherited)>();
foreach (var root in roots)
{
stack.Push((root, tag(root)));
}
while (stack.Count > 0)
{
var (node, inherited) = stack.Pop();
if (!result.TryAdd(node.AccountId, inherited))
{
continue;
}
foreach (var child in childrenByParent[node.AccountId])
{
stack.Push((child, tag(child) ?? inherited));
}
}
return result;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using General_Ledger_Service.Enums;
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class AuditLogRepository(AppDBContext context) : IAuditLogRepository
{
private readonly AppDBContext _context = context;
public async Task AddAsync(AuditLog log) =>
await _context.AuditLogs.AddAsync(log);
public async Task<(List<AuditLog> Items, int TotalCount)> ListAsync(
List<AuditCategory> categories, string? entityName, AuditAction? action,
string? performedBy, DateOnly? from, DateOnly? to, int? page, int? pageSize)
{
var query = _context.AuditLogs.Where(a => categories.Contains(a.Category));
if (!string.IsNullOrWhiteSpace(entityName))
{
query = query.Where(a => a.EntityName == entityName);
}
if (action.HasValue)
{
query = query.Where(a => a.Action == action.Value);
}
if (!string.IsNullOrWhiteSpace(performedBy))
{
query = query.Where(a => a.PerformedBy == performedBy);
}
if (from.HasValue)
{
var fromDateTime = from.Value.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
query = query.Where(a => a.OccurredAt >= fromDateTime);
}
if (to.HasValue)
{
var toDateTimeExclusive = to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
query = query.Where(a => a.OccurredAt < toDateTimeExclusive);
}
var totalCount = await query.CountAsync();
var items = await RepositoryHelpers.ApplyPaging(query.OrderByDescending(a => a.OccurredAt), page, pageSize)
.ToListAsync();
return (items, totalCount);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class BankRepository(AppDBContext context) : IBankRepository
{
private readonly AppDBContext _context = context;
public Task<BankAccount?> GetByIdAsync(long bankAccountId) =>
RepositoryHelpers.GetByIdAsync(_context.BankAccounts, bankAccountId);
public Task<BankAccount> AddAsync(BankAccount bankAccount) =>
RepositoryHelpers.AddAsync(_context.BankAccounts, bankAccount);
public async Task<List<BankAccount>> GetAllAsync() =>
await _context.BankAccounts.Include(b => b.GlAccount).OrderBy(b => b.AccountName).ToListAsync();
public Task<BankStatementLine?> GetStatementLineByIdAsync(long stmtLineId) =>
RepositoryHelpers.GetByIdAsync(_context.BankStatementLines, stmtLineId);
public async Task AddStatementLinesAsync(IEnumerable<BankStatementLine> lines) =>
await _context.BankStatementLines.AddRangeAsync(lines);
public Task<CashAccount> AddCashAccountAsync(CashAccount cashAccount) =>
RepositoryHelpers.AddAsync(_context.CashAccounts, cashAccount);
public async Task<List<CashAccount>> GetAllCashAccountsAsync() =>
await _context.CashAccounts.Include(c => c.CashAccountType).Include(c => c.GlAccount).OrderBy(c => c.AccountName).ToListAsync();
public async Task<CashAccount?> GetCashAccountByIdAsync(long cashAccountId) =>
await _context.CashAccounts.Include(c => c.CashAccountType).Include(c => c.GlAccount)
.FirstOrDefaultAsync(c => c.CashAccountId == cashAccountId);
public async Task<bool> AnyDefaultCashAccountAsync() =>
await _context.CashAccounts.AnyAsync(c => c.IsDefault);
public void RemoveCashAccount(CashAccount cashAccount) => _context.CashAccounts.Remove(cashAccount);
public async Task<bool> CashAccountNumberExistsAsync(string accountNumber) =>
await _context.CashAccounts.AnyAsync(c => c.AccountNumber == accountNumber);
public async Task<CashAccountType?> GetCashAccountTypeByNameAsync(string name) =>
await _context.CashAccountTypes.FirstOrDefaultAsync(t => t.Name.ToLower() == name.ToLower());
public Task<CashAccountType> AddCashAccountTypeAsync(CashAccountType cashAccountType) =>
RepositoryHelpers.AddAsync(_context.CashAccountTypes, cashAccountType);
public async Task<List<CashAccountType>> GetAllCashAccountTypesAsync() =>
await _context.CashAccountTypes.OrderBy(t => t.Name).ToListAsync();
}
}
+71
View File
@@ -0,0 +1,71 @@
using General_Ledger_Service.DTOs.Reports;
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class BudgetRepository(AppDBContext context) : IBudgetRepository
{
private readonly AppDBContext _context = context;
public Task<Budget?> GetByIdAsync(long budgetId) =>
RepositoryHelpers.GetByIdAsync(_context.Budgets, budgetId);
public Task<Budget> AddAsync(Budget budget) =>
RepositoryHelpers.AddAsync(_context.Budgets, budget);
public async Task AddLinesAsync(IEnumerable<BudgetLine> lines) =>
await _context.BudgetLines.AddRangeAsync(lines);
public async Task<List<Budget>> GetAllAsync() =>
await _context.Budgets.OrderBy(b => b.Name).ToListAsync();
public async Task<List<BudgetVsActualRowDto>> GetBudgetVsActualAsync(long budgetId)
{
var budgetLines = await _context.BudgetLines
.Where(bl => bl.BudgetId == budgetId)
.OrderBy(bl => bl.Account.AccountCode)
.Select(bl => new
{
bl.BudgetLineId,
bl.AccountId,
bl.Account.AccountCode,
bl.Account.AccountName,
bl.PeriodId,
bl.BudgetedAmount
})
.ToListAsync();
var accountIds = budgetLines.Select(bl => bl.AccountId).Distinct().ToList();
var periodIds = budgetLines.Select(bl => bl.PeriodId).Distinct().ToList();
// Actual = posted base debit - credit per (account, period), summed in the database.
var actuals = await _context.JournalEntryLines
.Where(l => l.Journal.IsPosted
&& accountIds.Contains(l.AccountId)
&& periodIds.Contains(l.Journal.PeriodId))
.GroupBy(l => new { l.AccountId, l.Journal.PeriodId })
.Select(g => new { g.Key.AccountId, g.Key.PeriodId, Amount = g.Sum(l => l.BaseDebit - l.BaseCredit) })
.ToListAsync();
var actualByAccountAndPeriod = actuals.ToDictionary(a => (a.AccountId, a.PeriodId), a => a.Amount);
return [.. budgetLines.Select(bl =>
{
actualByAccountAndPeriod.TryGetValue((bl.AccountId, bl.PeriodId), out var actual);
return new BudgetVsActualRowDto
{
BudgetLineId = bl.BudgetLineId,
AccountCode = bl.AccountCode,
AccountName = bl.AccountName,
PeriodId = bl.PeriodId,
BudgetedAmount = bl.BudgetedAmount,
ActualAmount = actual,
Variance = bl.BudgetedAmount - actual
};
})];
}
}
}
+103
View File
@@ -0,0 +1,103 @@
using General_Ledger_Service.Enums;
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class ChequeRepository(AppDBContext context) : IChequeRepository
{
private readonly AppDBContext _context = context;
public Task<ChequeBook> AddChequeBookAsync(ChequeBook chequeBook) =>
RepositoryHelpers.AddAsync(_context.ChequeBooks, chequeBook);
public Task<ChequeBook?> GetChequeBookByIdAsync(long chequeBookId) =>
RepositoryHelpers.GetByIdAsync(_context.ChequeBooks, chequeBookId);
public async Task<ChequeBook?> GetChequeBookByNoAsync(string chequeBookNo, bool includePages = false)
{
var query = _context.ChequeBooks.AsQueryable();
if (includePages)
{
query = query.Include(b => b.Pages);
}
return await query.FirstOrDefaultAsync(b => b.ChequeBookNo == chequeBookNo);
}
public Task<bool> ChequeBookNoExistsAsync(string chequeBookNo) =>
RepositoryHelpers.ExistsAsync(_context.ChequeBooks, b => b.ChequeBookNo == chequeBookNo);
public async Task<(List<ChequeBook> Items, int TotalCount)> ListChequeBooksAsync(
long? bankAccountId, long? branchId, ChequeBookStatus? status, int? page, int? pageSize)
{
var query = _context.ChequeBooks.AsQueryable();
if (bankAccountId.HasValue) query = query.Where(b => b.BankAccountId == bankAccountId.Value);
if (branchId.HasValue) query = query.Where(b => b.BranchId == branchId.Value);
if (status.HasValue) query = query.Where(b => b.Status == status.Value);
query = query.OrderByDescending(b => b.ReceivedDate).ThenBy(b => b.ChequeBookNo);
var totalCount = await query.CountAsync();
var items = await RepositoryHelpers.ApplyPaging(query, page, pageSize).ToListAsync();
return (items, totalCount);
}
public async Task AddChequePagesAsync(IEnumerable<ChequePage> pages) =>
await _context.ChequePages.AddRangeAsync(pages);
public async Task<ChequePage?> GetChequePageByNoAsync(string chequeNo) =>
await _context.ChequePages.Include(p => p.ChequeBook).FirstOrDefaultAsync(p => p.ChequeNo == chequeNo);
public Task<bool> ChequeNoExistsAsync(string chequeNo) =>
RepositoryHelpers.ExistsAsync(_context.ChequePages, p => p.ChequeNo == chequeNo);
public async Task<(List<ChequePage> Items, int TotalCount)> ListChequePagesAsync(
string? chequeBookNo, ChequeIssueStatus? issueStatus, ChequePayeeType? payeeType,
long? payeeId, int? page, int? pageSize)
{
var query = _context.ChequePages.Include(p => p.ChequeBook).AsQueryable();
if (!string.IsNullOrWhiteSpace(chequeBookNo)) query = query.Where(p => p.ChequeBook.ChequeBookNo == chequeBookNo);
if (issueStatus.HasValue) query = query.Where(p => p.IssueStatus == issueStatus.Value);
if (payeeType.HasValue) query = query.Where(p => p.PayeeType == payeeType.Value);
if (payeeId.HasValue) query = query.Where(p => p.PayeeId == payeeId.Value);
query = query.OrderBy(p => p.ChequeNo);
var totalCount = await query.CountAsync();
var items = await RepositoryHelpers.ApplyPaging(query, page, pageSize).ToListAsync();
return (items, totalCount);
}
public Task<ReceivedCheque> AddReceivedChequeAsync(ReceivedCheque receivedCheque) =>
RepositoryHelpers.AddAsync(_context.ReceivedCheques, receivedCheque);
public Task<ReceivedCheque?> GetReceivedChequeByIdAsync(long receivedChequeId) =>
RepositoryHelpers.GetByIdAsync(_context.ReceivedCheques, receivedChequeId);
public async Task<(List<ReceivedCheque> Items, int TotalCount)> ListReceivedChequesAsync(
long? companyId, long? branchId, ReceivedChequeStatus? status,
ReceivedChequeFromType? receivedFromType, int? page, int? pageSize)
{
var query = _context.ReceivedCheques.AsQueryable();
if (companyId.HasValue) query = query.Where(c => c.CompanyId == companyId.Value);
if (branchId.HasValue) query = query.Where(c => c.BranchId == branchId.Value);
if (status.HasValue) query = query.Where(c => c.Status == status.Value);
if (receivedFromType.HasValue) query = query.Where(c => c.ReceivedFromType == receivedFromType.Value);
query = query.OrderByDescending(c => c.ReceivedDate).ThenByDescending(c => c.ReceivedChequeId);
var totalCount = await query.CountAsync();
var items = await RepositoryHelpers.ApplyPaging(query, page, pageSize).ToListAsync();
return (items, totalCount);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using General_Ledger_Service.DTOs.CostCenters;
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class CostCenterRepository(AppDBContext context) : ICostCenterRepository
{
private readonly AppDBContext _context = context;
public Task<CostCenter?> GetByIdAsync(long costCenterId) =>
RepositoryHelpers.GetByIdAsync(_context.CostCenters, costCenterId);
public async Task<CostCenter?> GetByCodeAsync(string code) =>
await _context.CostCenters.FirstOrDefaultAsync(c => c.Code == code);
public async Task<int> GetAncestorDepthAsync(long parentCostCenterId) =>
RecursiveTreeQueries.GetAncestorDepth(await LoadTreeRowsAsync(), parentCostCenterId);
public async Task<string?> GetAncestorBreadcrumbAsync(long costCenterId) =>
RecursiveTreeQueries.GetAncestorBreadcrumb(await LoadTreeRowsAsync(), costCenterId);
public async Task<List<CostCenterTreeNode>> GetDescendantTreeAsync(long costCenterId)
{
var rows = RecursiveTreeQueries.GetDescendantTree(await LoadTreeRowsAsync(), costCenterId);
return [.. rows.Select(r => new CostCenterTreeNode
{
CostCenterId = r.Id,
Code = r.Code,
Name = r.Name,
ParentCostCenterId = r.ParentId,
Depth = r.Depth
})];
}
public Task<CostCenter> AddAsync(CostCenter costCenter) =>
RepositoryHelpers.AddAsync(_context.CostCenters, costCenter);
private async Task<List<RecursiveTreeQueries.TreeRow>> LoadTreeRowsAsync() =>
await _context.CostCenters.AsNoTracking()
.Select(c => new RecursiveTreeQueries.TreeRow
{
Id = c.CostCenterId,
Code = c.Code,
Name = c.Name,
ParentId = c.ParentCostCenterId
})
.ToListAsync();
}
}
+20
View File
@@ -0,0 +1,20 @@
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class CurrencyRepository(AppDBContext context) : ICurrencyRepository
{
private readonly AppDBContext _context = context;
public async Task<List<Currency>> GetAllAsync() =>
await _context.Currencies
.OrderBy(c => c.CurrencyCode)
.ToListAsync();
public async Task<string> GetBaseCurrencyCodeAsync() =>
(await _context.Currencies.FirstOrDefaultAsync(c => c.IsBaseCurrency))?.CurrencyCode ?? "LKR";
}
}
+40
View File
@@ -0,0 +1,40 @@
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class FiscalCalendarRepository(AppDBContext context) : IFiscalCalendarRepository
{
private readonly AppDBContext _context = context;
public Task<FiscalYear?> GetFiscalYearByIdAsync(int fiscalYearId) =>
RepositoryHelpers.GetByIdAsync(_context.FiscalYears, fiscalYearId);
public Task<bool> HasOverlappingFiscalYearRangeAsync(DateOnly startDate, DateOnly endDate) =>
RepositoryHelpers.ExistsAsync(_context.FiscalYears, fy => startDate <= fy.EndDate && endDate >= fy.StartDate);
public Task<FiscalYear> AddFiscalYearAsync(FiscalYear fiscalYear) =>
RepositoryHelpers.AddAsync(_context.FiscalYears, fiscalYear);
public Task<AccountingPeriod?> GetPeriodByIdAsync(int periodId) =>
RepositoryHelpers.GetByIdAsync(_context.AccountingPeriods, periodId);
public async Task<AccountingPeriod?> GetPeriodByDateAsync(DateOnly date) =>
await _context.AccountingPeriods
.FirstOrDefaultAsync(p => p.StartDate <= date && p.EndDate >= date);
public async Task<List<AccountingPeriod>> GetPeriodsByFiscalYearIdAsync(int fiscalYearId) =>
await _context.AccountingPeriods
.Where(p => p.FiscalYearId == fiscalYearId)
.OrderBy(p => p.PeriodNumber)
.ToListAsync();
public async Task AddPeriodRangeAsync(IEnumerable<AccountingPeriod> periods) =>
await _context.AccountingPeriods.AddRangeAsync(periods);
public Task<PeriodLockLog> AddPeriodLockLogAsync(PeriodLockLog log) =>
RepositoryHelpers.AddAsync(_context.PeriodLockLogs, log);
}
}
+49
View File
@@ -0,0 +1,49 @@
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class FixedAssetRepository(AppDBContext context) : IFixedAssetRepository
{
private readonly AppDBContext _context = context;
public Task<FixedAsset?> GetByIdAsync(long assetId) =>
RepositoryHelpers.GetByIdAsync(_context.FixedAssets, assetId);
public async Task<FixedAsset?> GetByCodeAsync(string assetCode) =>
await _context.FixedAssets.FirstOrDefaultAsync(a => a.AssetCode == assetCode);
public Task<FixedAsset> AddAsync(FixedAsset asset) =>
RepositoryHelpers.AddAsync(_context.FixedAssets, asset);
public async Task<List<FixedAsset>> GetAllAsync() =>
await _context.FixedAssets.OrderBy(a => a.AssetCode).ToListAsync();
public async Task<DepreciationSchedule?> GetLatestScheduleByAssetIdAsync(long assetId) =>
await _context.DepreciationSchedules
.Where(s => s.AssetId == assetId)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
public Task<DepreciationSchedule> AddScheduleAsync(DepreciationSchedule schedule) =>
RepositoryHelpers.AddAsync(_context.DepreciationSchedules, schedule);
public Task<AssetDisposal> AddDisposalAsync(AssetDisposal disposal) =>
RepositoryHelpers.AddAsync(_context.AssetDisposals, disposal);
// Accounting_System_Design_LKAS.md Section 11.5 - the only non-cash add-back this system
// currently tracks. ROUND(...,10) guards the same decimal-overflow class of bug fixed
// elsewhere in the report queries (03_Progress_Tracker.md's 2026-07-21 bugfix entry).
public async Task<decimal> GetDepreciationAddBackAsync(DateOnly periodStart, DateOnly periodEnd)
{
return await _context.DepreciationSchedules
.Where(ds => ds.Journal != null
&& ds.Journal.IsPosted
&& ds.Journal.EntryDate >= periodStart
&& ds.Journal.EntryDate <= periodEnd)
.SumAsync(ds => (decimal?)ds.DepreciationAmount) ?? 0m;
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using General_Ledger_Service.DTOs.Reports;
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class JournalEntryRepository(AppDBContext context) : IJournalEntryRepository
{
private readonly AppDBContext _context = context;
public async Task<JournalEntry?> GetJournalEntryByJournalNoAsync(string journalNo) =>
await _context.JournalEntries
.Include(j => j.Lines)
.FirstOrDefaultAsync(j => j.JournalNo == journalNo);
public async Task<int> CountJournalEntriesForYearAsync(int year) =>
await _context.JournalEntries.CountAsync(j => j.JournalNo.StartsWith($"JV-{year}-"));
public Task<JournalEntry> AddJournalEntryAsync(JournalEntry entry) =>
RepositoryHelpers.AddAsync(_context.JournalEntries, entry);
public async Task AddJournalEntryLinesAsync(IEnumerable<JournalEntryLine> lines) =>
await _context.JournalEntryLines.AddRangeAsync(lines);
public async Task<List<GeneralLedgerLineDto>> GetGeneralLedgerLinesAsync(long accountId, DateOnly periodStart, DateOnly periodEnd)
{
var treeRows = await _context.Accounts.AsNoTracking()
.Select(a => new RecursiveTreeQueries.TreeRow
{
Id = a.AccountId,
Code = a.AccountCode,
Name = a.AccountName,
ParentId = a.ParentAccountId
})
.ToListAsync();
var accountIds = RecursiveTreeQueries.GetDescendantIds(treeRows, accountId).ToList();
var lines = _context.JournalEntryLines
.Where(l => l.Journal.IsPosted
&& accountIds.Contains(l.AccountId)
&& l.Journal.EntryDate >= periodStart
&& l.Journal.EntryDate <= periodEnd);
return await BuildGeneralLedgerLinesAsync(lines);
}
public async Task<List<GeneralLedgerLineDto>> GetGeneralLedgerLinesForAllAccountsAsync(DateOnly periodStart, DateOnly periodEnd)
{
var lines = _context.JournalEntryLines
.Where(l => l.Journal.IsPosted
&& l.Account.IsPostable
&& l.Journal.EntryDate >= periodStart
&& l.Journal.EntryDate <= periodEnd);
return await BuildGeneralLedgerLinesAsync(lines);
}
// Orders by account code, then entry date/journal (the running balance restarts for each
// account and accumulates in that same date/journal order), then fetches the rows and
// computes the running balance in memory.
private static async Task<List<GeneralLedgerLineDto>> BuildGeneralLedgerLinesAsync(IQueryable<JournalEntryLine> lines)
{
var rows = await lines
.OrderBy(l => l.Account.AccountCode)
.ThenBy(l => l.Journal.EntryDate)
.ThenBy(l => l.JournalId)
.ThenBy(l => l.LineId)
.Select(l => new
{
l.AccountId,
l.Journal.EntryDate,
l.Journal.JournalNo,
l.Account.AccountCode,
l.Account.AccountName,
l.Journal.Narration,
l.DebitAmount,
l.CreditAmount
})
.ToListAsync();
var runningBalances = new Dictionary<long, decimal>();
var result = new List<GeneralLedgerLineDto>(rows.Count);
foreach (var row in rows)
{
runningBalances.TryGetValue(row.AccountId, out var previous);
var running = previous + row.DebitAmount - row.CreditAmount;
runningBalances[row.AccountId] = running;
result.Add(new GeneralLedgerLineDto
{
EntryDate = row.EntryDate,
JournalNo = row.JournalNo,
AccountCode = row.AccountCode,
AccountName = row.AccountName,
Narration = row.Narration,
DebitAmount = row.DebitAmount,
CreditAmount = row.CreditAmount,
RunningBalance = running
});
}
return result;
}
public Task<JournalCorrection> AddJournalCorrectionAsync(JournalCorrection correction) =>
RepositoryHelpers.AddAsync(_context.JournalCorrections, correction);
}
}
@@ -0,0 +1,20 @@
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class RecurringJournalTemplateRepository(AppDBContext context) : IRecurringJournalTemplateRepository
{
private readonly AppDBContext _context = context;
public async Task<RecurringJournalTemplate?> GetByIdAsync(long templateId) =>
await _context.RecurringJournalTemplates
.Include(t => t.Lines)
.FirstOrDefaultAsync(t => t.TemplateId == templateId);
public Task<RecurringJournalTemplate> AddAsync(RecurringJournalTemplate template) =>
RepositoryHelpers.AddAsync(_context.RecurringJournalTemplates, template);
}
}
+18
View File
@@ -0,0 +1,18 @@
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class SystemConfigRepository(AppDBContext context) : ISystemConfigRepository
{
private readonly AppDBContext _context = context;
public async Task<SystemConfig?> GetByKeyAsync(string configKey) =>
await _context.SystemConfigs.FirstOrDefaultAsync(c => c.ConfigKey == configKey);
public async Task<List<SystemConfig>> GetAllAsync() =>
await _context.SystemConfigs.OrderBy(c => c.ConfigKey).ToListAsync();
}
}
+62
View File
@@ -0,0 +1,62 @@
using General_Ledger_Service.Infra;
using General_Ledger_Service.Interfaces.Repositories;
using General_Ledger_Service.Models;
using Microsoft.EntityFrameworkCore;
namespace General_Ledger_Service.Repositories
{
public class TaxRepository(AppDBContext context) : ITaxRepository
{
private readonly AppDBContext _context = context;
public async Task<TaxType?> GetTaxTypeByCodeAsync(string code) =>
await _context.TaxTypes.FirstOrDefaultAsync(t => t.Code == code);
public async Task<List<TaxType>> GetAllTaxTypesAsync() =>
await _context.TaxTypes.OrderBy(t => t.Code).ToListAsync();
public async Task<TaxCode?> GetEffectiveTaxCodeByCodeAsync(string code, DateOnly txnDate) =>
await _context.TaxCodes.FirstOrDefaultAsync(tc =>
tc.Code == code &&
tc.EffectiveFrom <= txnDate &&
(tc.EffectiveTo == null || tc.EffectiveTo >= txnDate));
public async Task<TaxCode?> GetByCodeAsync(string code) =>
await _context.TaxCodes.FirstOrDefaultAsync(tc => tc.Code == code);
public Task<TaxCode> AddTaxCodeAsync(TaxCode taxCode) =>
RepositoryHelpers.AddAsync(_context.TaxCodes, taxCode);
public async Task<List<TaxCode>> GetAllTaxCodesAsync() =>
await _context.TaxCodes.OrderBy(tc => tc.Code).ToListAsync();
// Accounting_System_Design_LKAS.md Section 11.7 - Non-Deductible Expenses. Expense is
// debit-normal, so base_credit - base_debit is negative for a normal balance; ABS() here
// (rather than in the Service layer) since this figure is always presented as a positive
// "Add:" line, same convention ReportService applies for the P&L's Expense-side sections.
public async Task<decimal> GetNonDeductibleExpensesAsync(DateOnly periodStart, DateOnly periodEnd)
{
var net = await _context.JournalEntryLines
.Where(l => l.Journal.IsPosted
&& l.Journal.EntryDate >= periodStart
&& l.Journal.EntryDate <= periodEnd
&& l.Account.AccountTypeId == 5
&& !l.Account.IsTaxDeductible)
.SumAsync(l => (decimal?)(l.BaseCredit - l.BaseDebit)) ?? 0m;
return Math.Abs(net);
}
// Reused for APIT/WHT/quarterly-tax-payment credit accounts (Section 11.7, steps 8-10) -
// each is a plain credit-normal balance sum against one configured accounts.account_id.
public async Task<decimal> GetAccountCreditActivityAsync(long accountId, DateOnly periodStart, DateOnly periodEnd)
{
return await _context.JournalEntryLines
.Where(l => l.Journal.IsPosted
&& l.Journal.EntryDate >= periodStart
&& l.Journal.EntryDate <= periodEnd
&& l.AccountId == accountId)
.SumAsync(l => (decimal?)(l.BaseCredit - l.BaseDebit)) ?? 0m;
}
}
}