Enhance financial report accuracy by including closing journals in profit and loss calculations and trial balances; add idempotency check for journal entry creation.
This commit is contained in:
@@ -18,7 +18,7 @@ namespace General_Ledger_Service.Interfaces.Repositories
|
|||||||
Task<Account> AddAsync(Account account);
|
Task<Account> AddAsync(Account account);
|
||||||
Task<List<TrialBalanceRowDto>> GetTrialBalanceAsync(DateOnly asOfDate);
|
Task<List<TrialBalanceRowDto>> GetTrialBalanceAsync(DateOnly asOfDate);
|
||||||
Task<List<BalanceSheetLeafRowDto>> GetBalanceSheetLeafRowsAsync(DateOnly asOfDate);
|
Task<List<BalanceSheetLeafRowDto>> GetBalanceSheetLeafRowsAsync(DateOnly asOfDate);
|
||||||
Task<List<ProfitAndLossLeafRowDto>> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd);
|
Task<List<ProfitAndLossLeafRowDto>> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd, bool includeClosing = false);
|
||||||
Task<List<CashFlowWorkingCapitalChangeDto>> GetCashFlowWorkingCapitalChangesAsync(DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId);
|
Task<List<CashFlowWorkingCapitalChangeDto>> GetCashFlowWorkingCapitalChangesAsync(DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId);
|
||||||
Task<List<CashFlowLineDto>> GetCashFlowCategoryLinesAsync(string category, DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId);
|
Task<List<CashFlowLineDto>> GetCashFlowCategoryLinesAsync(string category, DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId);
|
||||||
Task<decimal> GetCashBalanceAsync(DateOnly asOfDate, long cashAccountRootId);
|
Task<decimal> GetCashBalanceAsync(DateOnly asOfDate, long cashAccountRootId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace General_Ledger_Service.Interfaces.Repositories
|
|||||||
public interface IJournalEntryRepository
|
public interface IJournalEntryRepository
|
||||||
{
|
{
|
||||||
Task<JournalEntry?> GetJournalEntryByJournalNoAsync(string journalNo);
|
Task<JournalEntry?> GetJournalEntryByJournalNoAsync(string journalNo);
|
||||||
|
Task<JournalEntry?> GetJournalEntryBySourceReferenceAsync(string sourceModule, string reference);
|
||||||
Task<int> CountJournalEntriesForYearAsync(int year);
|
Task<int> CountJournalEntriesForYearAsync(int year);
|
||||||
Task<JournalEntry> AddJournalEntryAsync(JournalEntry entry);
|
Task<JournalEntry> AddJournalEntryAsync(JournalEntry entry);
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ namespace General_Ledger_Service.Repositories
|
|||||||
// its reversal) showed equal/duplicated figures in both columns.
|
// its reversal) showed equal/duplicated figures in both columns.
|
||||||
public async Task<List<TrialBalanceRowDto>> GetTrialBalanceAsync(DateOnly asOfDate)
|
public async Task<List<TrialBalanceRowDto>> GetTrialBalanceAsync(DateOnly asOfDate)
|
||||||
{
|
{
|
||||||
var totals = await GetPostedTotalsAsync(null, asOfDate);
|
// Post-closing trial balance: includes year-end closing journals (JournalType CLOSING).
|
||||||
|
var totals = await GetPostedTotalsAsync(null, asOfDate, includeClosing: true);
|
||||||
var postableAccounts = await _context.Accounts.AsNoTracking().Where(a => a.IsPostable).ToListAsync();
|
var postableAccounts = await _context.Accounts.AsNoTracking().Where(a => a.IsPostable).ToListAsync();
|
||||||
|
|
||||||
return [.. postableAccounts
|
return [.. postableAccounts
|
||||||
@@ -120,7 +121,8 @@ namespace General_Ledger_Service.Repositories
|
|||||||
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
|
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 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 classification = InheritTag(accounts, roots, a => a.BalanceSheetClassification);
|
||||||
var totals = await GetPostedTotalsAsync(null, asOfDate);
|
// Includes closing journals so Retained Earnings carries every closed year's profit.
|
||||||
|
var totals = await GetPostedTotalsAsync(null, asOfDate, includeClosing: true);
|
||||||
|
|
||||||
return [.. accounts
|
return [.. accounts
|
||||||
.Where(a => a.IsPostable && classification.ContainsKey(a.AccountId) && totals.ContainsKey(a.AccountId))
|
.Where(a => a.IsPostable && classification.ContainsKey(a.AccountId) && totals.ContainsKey(a.AccountId))
|
||||||
@@ -141,12 +143,12 @@ namespace General_Ledger_Service.Repositories
|
|||||||
// pattern as cash_flow_category); ReportService groups these into the nested
|
// pattern as cash_flow_category); ReportService groups these into the nested
|
||||||
// ProfitAndLossReportDto and applies the Expense-side sign flip (Amount here is the raw,
|
// ProfitAndLossReportDto and applies the Expense-side sign flip (Amount here is the raw,
|
||||||
// unflipped credit - debit - negative for a normal debit-balance Expense account).
|
// unflipped credit - debit - negative for a normal debit-balance Expense account).
|
||||||
public async Task<List<ProfitAndLossLeafRowDto>> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd)
|
public async Task<List<ProfitAndLossLeafRowDto>> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd, bool includeClosing = false)
|
||||||
{
|
{
|
||||||
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
|
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
|
||||||
var roots = accounts.Where(a => a.ParentAccountId == null && (a.AccountTypeId == 4 || a.AccountTypeId == 5));
|
var roots = accounts.Where(a => a.ParentAccountId == null && (a.AccountTypeId == 4 || a.AccountTypeId == 5));
|
||||||
var section = InheritTag(accounts, roots, a => a.IncomeStatementSection);
|
var section = InheritTag(accounts, roots, a => a.IncomeStatementSection);
|
||||||
var totals = await GetPostedTotalsAsync(periodStart, periodEnd);
|
var totals = await GetPostedTotalsAsync(periodStart, periodEnd, includeClosing);
|
||||||
|
|
||||||
return [.. accounts
|
return [.. accounts
|
||||||
.Where(a => a.IsPostable && section.ContainsKey(a.AccountId) && totals.ContainsKey(a.AccountId))
|
.Where(a => a.IsPostable && section.ContainsKey(a.AccountId) && totals.ContainsKey(a.AccountId))
|
||||||
@@ -254,10 +256,21 @@ namespace General_Ledger_Service.Repositories
|
|||||||
// Per-account base debit/credit totals over posted journal entries, optionally bounded
|
// 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
|
// by entry date (inclusive on both ends). The sums run in the database; accounts with no
|
||||||
// matching lines are simply absent from the result.
|
// matching lines are simply absent from the result.
|
||||||
private async Task<Dictionary<long, (decimal Debit, decimal Credit)>> GetPostedTotalsAsync(DateOnly? from, DateOnly? to)
|
// Year-end closing journals (JournalType == ClosingJournalType) move a year's income/expense into
|
||||||
|
// Retained Earnings. They are excluded unless includeClosing is true, so the P&L, cash flow and
|
||||||
|
// similar period reports still show a closed year's own activity; the balance sheet and trial
|
||||||
|
// balance include them so every account's balance is complete.
|
||||||
|
public const string ClosingJournalType = "CLOSING";
|
||||||
|
|
||||||
|
private async Task<Dictionary<long, (decimal Debit, decimal Credit)>> GetPostedTotalsAsync(DateOnly? from, DateOnly? to, bool includeClosing = false)
|
||||||
{
|
{
|
||||||
var lines = _context.JournalEntryLines.Where(l => l.Journal.IsPosted);
|
var lines = _context.JournalEntryLines.Where(l => l.Journal.IsPosted);
|
||||||
|
|
||||||
|
if (!includeClosing)
|
||||||
|
{
|
||||||
|
lines = lines.Where(l => l.Journal.JournalType != ClosingJournalType);
|
||||||
|
}
|
||||||
|
|
||||||
if (from.HasValue)
|
if (from.HasValue)
|
||||||
{
|
{
|
||||||
lines = lines.Where(l => l.Journal.EntryDate >= from.Value);
|
lines = lines.Where(l => l.Journal.EntryDate >= from.Value);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ namespace General_Ledger_Service.Repositories
|
|||||||
// Actual = posted base debit - credit per (account, period), summed in the database.
|
// Actual = posted base debit - credit per (account, period), summed in the database.
|
||||||
var actuals = await _context.JournalEntryLines
|
var actuals = await _context.JournalEntryLines
|
||||||
.Where(l => l.Journal.IsPosted
|
.Where(l => l.Journal.IsPosted
|
||||||
|
&& l.Journal.JournalType != "CLOSING"
|
||||||
&& accountIds.Contains(l.AccountId)
|
&& accountIds.Contains(l.AccountId)
|
||||||
&& periodIds.Contains(l.Journal.PeriodId))
|
&& periodIds.Contains(l.Journal.PeriodId))
|
||||||
.GroupBy(l => new { l.AccountId, l.Journal.PeriodId })
|
.GroupBy(l => new { l.AccountId, l.Journal.PeriodId })
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ namespace General_Ledger_Service.Repositories
|
|||||||
.Include(j => j.Lines)
|
.Include(j => j.Lines)
|
||||||
.FirstOrDefaultAsync(j => j.JournalNo == journalNo);
|
.FirstOrDefaultAsync(j => j.JournalNo == journalNo);
|
||||||
|
|
||||||
|
public async Task<JournalEntry?> GetJournalEntryBySourceReferenceAsync(string sourceModule, string reference) =>
|
||||||
|
await _context.JournalEntries
|
||||||
|
.Include(j => j.Lines)
|
||||||
|
.FirstOrDefaultAsync(j => j.SourceModule == sourceModule && j.Reference == reference);
|
||||||
|
|
||||||
public async Task<int> CountJournalEntriesForYearAsync(int year) =>
|
public async Task<int> CountJournalEntriesForYearAsync(int year) =>
|
||||||
await _context.JournalEntries.CountAsync(j => j.JournalNo.StartsWith($"JV-{year}-"));
|
await _context.JournalEntries.CountAsync(j => j.JournalNo.StartsWith($"JV-{year}-"));
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace General_Ledger_Service.Repositories
|
|||||||
{
|
{
|
||||||
var net = await _context.JournalEntryLines
|
var net = await _context.JournalEntryLines
|
||||||
.Where(l => l.Journal.IsPosted
|
.Where(l => l.Journal.IsPosted
|
||||||
|
&& l.Journal.JournalType != "CLOSING"
|
||||||
&& l.Journal.EntryDate >= periodStart
|
&& l.Journal.EntryDate >= periodStart
|
||||||
&& l.Journal.EntryDate <= periodEnd
|
&& l.Journal.EntryDate <= periodEnd
|
||||||
&& l.Account.AccountTypeId == 5
|
&& l.Account.AccountTypeId == 5
|
||||||
@@ -53,6 +54,7 @@ namespace General_Ledger_Service.Repositories
|
|||||||
{
|
{
|
||||||
return await _context.JournalEntryLines
|
return await _context.JournalEntryLines
|
||||||
.Where(l => l.Journal.IsPosted
|
.Where(l => l.Journal.IsPosted
|
||||||
|
&& l.Journal.JournalType != "CLOSING"
|
||||||
&& l.Journal.EntryDate >= periodStart
|
&& l.Journal.EntryDate >= periodStart
|
||||||
&& l.Journal.EntryDate <= periodEnd
|
&& l.Journal.EntryDate <= periodEnd
|
||||||
&& l.AccountId == accountId)
|
&& l.AccountId == accountId)
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ namespace General_Ledger_Service.Services.CsvDocuments
|
|||||||
public static byte[] Generate(List<GeneralLedgerLineDto> rows)
|
public static byte[] Generate(List<GeneralLedgerLineDto> rows)
|
||||||
{
|
{
|
||||||
var csv = new CsvBuilder();
|
var csv = new CsvBuilder();
|
||||||
csv.AddRow("Account Code", "Account Name", "Date", "Particulars", "J.R. (Journal No.)", "Debit", "Credit", "Balance");
|
csv.AddRow("Account Code", "Account Name", "Date", "Particulars", "J.R. (Journal No.)", "Debit", "Credit");
|
||||||
|
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
csv.AddRow(
|
csv.AddRow(
|
||||||
row.AccountCode, row.AccountName, row.EntryDate, row.Narration, row.JournalNo,
|
row.AccountCode, row.AccountName, row.EntryDate, row.Narration, row.JournalNo,
|
||||||
row.DebitAmount == 0 ? null : row.DebitAmount,
|
row.DebitAmount == 0 ? null : row.DebitAmount,
|
||||||
row.CreditAmount == 0 ? null : row.CreditAmount,
|
row.CreditAmount == 0 ? null : row.CreditAmount);
|
||||||
row.RunningBalance);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return csv.Build();
|
return csv.Build();
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ namespace General_Ledger_Service.Services
|
|||||||
|
|
||||||
public async Task<ApiResponse> CreateAsync(CreateJournalEntryRequest request)
|
public async Task<ApiResponse> CreateAsync(CreateJournalEntryRequest request)
|
||||||
{
|
{
|
||||||
|
// Idempotency guard for service-to-service callers: the same sourceModule + reference is only ever
|
||||||
|
// posted once, so a retry after a timeout returns the existing entry instead of double-posting.
|
||||||
|
if (request.Correction is null
|
||||||
|
&& !string.IsNullOrWhiteSpace(request.SourceModule) && !string.IsNullOrWhiteSpace(request.Reference))
|
||||||
|
{
|
||||||
|
var existing = await _journalEntryRepository.GetJournalEntryBySourceReferenceAsync(request.SourceModule, request.Reference);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
return ResponseHelpers.Ok("Journal entry already exists for this sourceModule and reference", existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var period = await _fiscalCalendarRepository.GetPeriodByIdAsync(request.PeriodId)
|
var period = await _fiscalCalendarRepository.GetPeriodByIdAsync(request.PeriodId)
|
||||||
?? throw new NotFoundException("Period not found");
|
?? throw new NotFoundException("Period not found");
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ using QuestPDF.Infrastructure;
|
|||||||
|
|
||||||
namespace General_Ledger_Service.Services.PdfDocuments
|
namespace General_Ledger_Service.Services.PdfDocuments
|
||||||
{
|
{
|
||||||
// 05_LKAS_Report_PDF_Templates.md Section 6 - one continuous table (not separate boxed
|
// One continuous table of transaction lines, no grouping by account: each row carries its own
|
||||||
// sections per account), account boundaries marked by an inline full-width group-header row.
|
// account (code + name), same layout as the CSV and the on-screen report.
|
||||||
// Response is already sorted by accountCode then entryDate - no re-sorting needed here.
|
// 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 class GeneralLedgerPdfDocument(string companyName, string currencyCode, DateOnly periodStart, DateOnly periodEnd, List<GeneralLedgerLineDto> rows) : IDocument
|
||||||
{
|
{
|
||||||
@@ -26,39 +26,31 @@ namespace General_Ledger_Service.Services.PdfDocuments
|
|||||||
{
|
{
|
||||||
columns.RelativeColumn(2);
|
columns.RelativeColumn(2);
|
||||||
columns.RelativeColumn(4);
|
columns.RelativeColumn(4);
|
||||||
|
columns.RelativeColumn(4);
|
||||||
columns.RelativeColumn(3);
|
columns.RelativeColumn(3);
|
||||||
columns.RelativeColumn(2);
|
columns.RelativeColumn(2);
|
||||||
columns.RelativeColumn(2);
|
columns.RelativeColumn(2);
|
||||||
columns.RelativeColumn(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
table.Header(header =>
|
table.Header(header =>
|
||||||
{
|
{
|
||||||
header.Cell().Text("Date").Bold();
|
header.Cell().Text("Date").Bold();
|
||||||
|
header.Cell().Text("Account").Bold();
|
||||||
header.Cell().Text("Particulars").Bold();
|
header.Cell().Text("Particulars").Bold();
|
||||||
header.Cell().Text("Description/Reference (J.R.)").Bold();
|
header.Cell().Text("Description/Reference (J.R.)").Bold();
|
||||||
header.Cell().AlignRight().Text("Debit").Bold();
|
header.Cell().AlignRight().Text("Debit").Bold();
|
||||||
header.Cell().AlignRight().Text("Credit").Bold();
|
header.Cell().AlignRight().Text("Credit").Bold();
|
||||||
header.Cell().AlignRight().Text("Balance").Bold();
|
|
||||||
header.Cell().ColumnSpan(6).PaddingTop(3).LineHorizontal(1);
|
header.Cell().ColumnSpan(6).PaddingTop(3).LineHorizontal(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
string? currentAccountCode = null;
|
|
||||||
foreach (var row in rows)
|
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.EntryDate.ToString("yyyy-MM-dd"));
|
||||||
|
table.Cell().Text($"{row.AccountCode} {row.AccountName}");
|
||||||
table.Cell().Text(row.Narration ?? string.Empty);
|
table.Cell().Text(row.Narration ?? string.Empty);
|
||||||
table.Cell().Text(row.JournalNo);
|
table.Cell().Text(row.JournalNo);
|
||||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmountOrBlank(row.DebitAmount));
|
table.Cell().AlignRight().Text(PdfFormatting.FormatAmountOrBlank(row.DebitAmount));
|
||||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmountOrBlank(row.CreditAmount));
|
table.Cell().AlignRight().Text(PdfFormatting.FormatAmountOrBlank(row.CreditAmount));
|
||||||
table.Cell().AlignRight().Text(PdfFormatting.FormatAmount(row.RunningBalance));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
table.Cell().ColumnSpan(6).PaddingTop(3).LineHorizontal(1);
|
table.Cell().ColumnSpan(6).PaddingTop(3).LineHorizontal(1);
|
||||||
|
|||||||
@@ -189,8 +189,13 @@ namespace General_Ledger_Service.Services
|
|||||||
// project owner 2026-07-20 - see CLAUDE.md/03_Progress_Tracker.md. Folded into 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
|
// 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.
|
// top-level field, since it's conceptually an Equity line item.
|
||||||
var profitAndLoss = await BuildProfitAndLossReportAsync(DateOnly.MinValue, asOfDate);
|
var profitAndLoss = await BuildProfitAndLossReportAsync(DateOnly.MinValue, asOfDate, includeClosing: true);
|
||||||
equity.Lines.Add(new BalanceSheetLineDto { AccountCode = "", AccountName = "Current Year Earnings", Balance = profitAndLoss.NetProfitForPeriod });
|
|
||||||
|
// Equity shows the real equity accounts plus ONE calculated line: the profit of the period not yet
|
||||||
|
// closed into Retained Earnings. Closing journals are included above, so once a year is closed its
|
||||||
|
// profit sits in Retained Earnings and this line only carries the open period. The income/expense
|
||||||
|
// detail belongs to the ProfitAndLoss report, not the balance sheet.
|
||||||
|
equity.Lines.Add(new BalanceSheetLineDto { AccountCode = "", AccountName = "Current Year Profit", Balance = profitAndLoss.NetProfitForPeriod });
|
||||||
equity.Total += profitAndLoss.NetProfitForPeriod;
|
equity.Total += profitAndLoss.NetProfitForPeriod;
|
||||||
|
|
||||||
var totalAssets = nonCurrentAssets.Total + currentAssets.Total + unclassifiedAssets.Total;
|
var totalAssets = nonCurrentAssets.Total + currentAssets.Total + unclassifiedAssets.Total;
|
||||||
@@ -223,9 +228,9 @@ namespace General_Ledger_Service.Services
|
|||||||
// profitForPeriod - all three just need the resulting NetProfitForPeriod figure, computed
|
// 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
|
// once here from the raw leaf rows so the Expense-side sign convention lives in exactly
|
||||||
// one place.
|
// one place.
|
||||||
private async Task<ProfitAndLossReportDto> BuildProfitAndLossReportAsync(DateOnly periodStart, DateOnly periodEnd)
|
private async Task<ProfitAndLossReportDto> BuildProfitAndLossReportAsync(DateOnly periodStart, DateOnly periodEnd, bool includeClosing = false)
|
||||||
{
|
{
|
||||||
var leafRows = await _accountRepository.GetProfitAndLossLeafRowsAsync(periodStart, periodEnd);
|
var leafRows = await _accountRepository.GetProfitAndLossLeafRowsAsync(periodStart, periodEnd, includeClosing);
|
||||||
|
|
||||||
// Raw Amount (credit - debit) is already positive-normal for Income (account_type_id
|
// 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
|
// 4) but negative-normal for Expense (account_type_id 5, debit-normal) - flip Expense
|
||||||
|
|||||||
Reference in New Issue
Block a user