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:
2026-10-20 18:52:04 +05:30
parent 3f16e0cb44
commit 21c7ba54ea
10 changed files with 56 additions and 26 deletions
@@ -7,15 +7,14 @@ namespace General_Ledger_Service.Services.CsvDocuments
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");
csv.AddRow("Account Code", "Account Name", "Date", "Particulars", "J.R. (Journal No.)", "Debit", "Credit");
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);
row.CreditAmount == 0 ? null : row.CreditAmount);
}
return csv.Build();
+12
View File
@@ -31,6 +31,18 @@ namespace General_Ledger_Service.Services
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)
?? throw new NotFoundException("Period not found");
@@ -5,8 +5,8 @@ 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.
// One continuous table of transaction lines, no grouping by account: each row carries its own
// 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.
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(4);
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("Account").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.AccountCode} {row.AccountName}");
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);
+9 -4
View File
@@ -189,8 +189,13 @@ namespace General_Ledger_Service.Services
// 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 });
var profitAndLoss = await BuildProfitAndLossReportAsync(DateOnly.MinValue, asOfDate, includeClosing: true);
// 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;
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
// 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)
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
// 4) but negative-normal for Expense (account_type_id 5, debit-normal) - flip Expense