diff --git a/Interfaces/Repositories/IAccountRepository.cs b/Interfaces/Repositories/IAccountRepository.cs index eb743d6..47460ab 100644 --- a/Interfaces/Repositories/IAccountRepository.cs +++ b/Interfaces/Repositories/IAccountRepository.cs @@ -18,7 +18,7 @@ namespace General_Ledger_Service.Interfaces.Repositories Task AddAsync(Account account); Task> GetTrialBalanceAsync(DateOnly asOfDate); Task> GetBalanceSheetLeafRowsAsync(DateOnly asOfDate); - Task> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd); + Task> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd, bool includeClosing = false); Task> GetCashFlowWorkingCapitalChangesAsync(DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId); Task> GetCashFlowCategoryLinesAsync(string category, DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId); Task GetCashBalanceAsync(DateOnly asOfDate, long cashAccountRootId); diff --git a/Interfaces/Repositories/IJournalEntryRepository.cs b/Interfaces/Repositories/IJournalEntryRepository.cs index ab9e150..8888957 100644 --- a/Interfaces/Repositories/IJournalEntryRepository.cs +++ b/Interfaces/Repositories/IJournalEntryRepository.cs @@ -6,6 +6,7 @@ namespace General_Ledger_Service.Interfaces.Repositories public interface IJournalEntryRepository { Task GetJournalEntryByJournalNoAsync(string journalNo); + Task GetJournalEntryBySourceReferenceAsync(string sourceModule, string reference); Task CountJournalEntriesForYearAsync(int year); Task AddJournalEntryAsync(JournalEntry entry); diff --git a/Repositories/AccountRepository.cs b/Repositories/AccountRepository.cs index e8d8654..573cd3f 100644 --- a/Repositories/AccountRepository.cs +++ b/Repositories/AccountRepository.cs @@ -88,7 +88,8 @@ namespace General_Ledger_Service.Repositories // its reversal) showed equal/duplicated figures in both columns. public async Task> 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(); return [.. postableAccounts @@ -120,7 +121,8 @@ namespace General_Ledger_Service.Repositories 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); + // Includes closing journals so Retained Earnings carries every closed year's profit. + var totals = await GetPostedTotalsAsync(null, asOfDate, includeClosing: true); return [.. accounts .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 // 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> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd) + public async Task> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd, bool includeClosing = false) { 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); + var totals = await GetPostedTotalsAsync(periodStart, periodEnd, includeClosing); return [.. accounts .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 // 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> 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> GetPostedTotalsAsync(DateOnly? from, DateOnly? to, bool includeClosing = false) { var lines = _context.JournalEntryLines.Where(l => l.Journal.IsPosted); + if (!includeClosing) + { + lines = lines.Where(l => l.Journal.JournalType != ClosingJournalType); + } + if (from.HasValue) { lines = lines.Where(l => l.Journal.EntryDate >= from.Value); diff --git a/Repositories/BudgetRepository.cs b/Repositories/BudgetRepository.cs index bd016e9..b866ea3 100644 --- a/Repositories/BudgetRepository.cs +++ b/Repositories/BudgetRepository.cs @@ -44,6 +44,7 @@ namespace General_Ledger_Service.Repositories // Actual = posted base debit - credit per (account, period), summed in the database. var actuals = await _context.JournalEntryLines .Where(l => l.Journal.IsPosted + && l.Journal.JournalType != "CLOSING" && accountIds.Contains(l.AccountId) && periodIds.Contains(l.Journal.PeriodId)) .GroupBy(l => new { l.AccountId, l.Journal.PeriodId }) diff --git a/Repositories/JournalEntryRepository.cs b/Repositories/JournalEntryRepository.cs index 1e4b00f..ae16648 100644 --- a/Repositories/JournalEntryRepository.cs +++ b/Repositories/JournalEntryRepository.cs @@ -15,6 +15,11 @@ namespace General_Ledger_Service.Repositories .Include(j => j.Lines) .FirstOrDefaultAsync(j => j.JournalNo == journalNo); + public async Task GetJournalEntryBySourceReferenceAsync(string sourceModule, string reference) => + await _context.JournalEntries + .Include(j => j.Lines) + .FirstOrDefaultAsync(j => j.SourceModule == sourceModule && j.Reference == reference); + public async Task CountJournalEntriesForYearAsync(int year) => await _context.JournalEntries.CountAsync(j => j.JournalNo.StartsWith($"JV-{year}-")); diff --git a/Repositories/TaxRepository.cs b/Repositories/TaxRepository.cs index ec26b98..2f2a34c 100644 --- a/Repositories/TaxRepository.cs +++ b/Repositories/TaxRepository.cs @@ -38,6 +38,7 @@ namespace General_Ledger_Service.Repositories { var net = await _context.JournalEntryLines .Where(l => l.Journal.IsPosted + && l.Journal.JournalType != "CLOSING" && l.Journal.EntryDate >= periodStart && l.Journal.EntryDate <= periodEnd && l.Account.AccountTypeId == 5 @@ -53,6 +54,7 @@ namespace General_Ledger_Service.Repositories { return await _context.JournalEntryLines .Where(l => l.Journal.IsPosted + && l.Journal.JournalType != "CLOSING" && l.Journal.EntryDate >= periodStart && l.Journal.EntryDate <= periodEnd && l.AccountId == accountId) diff --git a/Services/CsvDocuments/GeneralLedgerCsvDocument.cs b/Services/CsvDocuments/GeneralLedgerCsvDocument.cs index 21ede39..52b1bfd 100644 --- a/Services/CsvDocuments/GeneralLedgerCsvDocument.cs +++ b/Services/CsvDocuments/GeneralLedgerCsvDocument.cs @@ -7,15 +7,14 @@ namespace General_Ledger_Service.Services.CsvDocuments public static byte[] Generate(List 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(); diff --git a/Services/JournalEntryService.cs b/Services/JournalEntryService.cs index 397198a..58b9b5c 100644 --- a/Services/JournalEntryService.cs +++ b/Services/JournalEntryService.cs @@ -31,6 +31,18 @@ namespace General_Ledger_Service.Services public async Task 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"); diff --git a/Services/PdfDocuments/GeneralLedgerPdfDocument.cs b/Services/PdfDocuments/GeneralLedgerPdfDocument.cs index 6a660d9..fdfbaf4 100644 --- a/Services/PdfDocuments/GeneralLedgerPdfDocument.cs +++ b/Services/PdfDocuments/GeneralLedgerPdfDocument.cs @@ -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 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); diff --git a/Services/ReportService.cs b/Services/ReportService.cs index 72f63eb..6980e59 100644 --- a/Services/ReportService.cs +++ b/Services/ReportService.cs @@ -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 BuildProfitAndLossReportAsync(DateOnly periodStart, DateOnly periodEnd) + private async Task 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