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 GenerateAsync(ListReportsRequest request) { if (!Enum.TryParse(request.ReportType, ignoreCase: true, out var reportType)) { throw new BadRequestException( $"Invalid reportType '{request.ReportType}'. Must be one of: {string.Join(", ", Enum.GetNames())}."); } if (!Enum.TryParse(request.OutputFormat, ignoreCase: true, out var outputFormat)) { throw new BadRequestException( $"Invalid outputFormat '{request.OutputFormat}'. Must be one of: {string.Join(", ", Enum.GetNames())}."); } 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)data), ReportType.BalanceSheet => BalanceSheetCsvDocument.Generate((BalanceSheetReportDto)data), ReportType.ProfitAndLoss => ProfitAndLossCsvDocument.Generate((ProfitAndLossReportDto)data), ReportType.CashFlow => CashFlowCsvDocument.Generate((CashFlowReportDto)data), ReportType.GeneralLedger => GeneralLedgerCsvDocument.Generate((List)data), ReportType.BudgetVsActual => BudgetVsActualCsvDocument.Generate((List)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 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)data), ReportType.BudgetVsActual => new BudgetVsActualPdfDocument(companyName, currencyCode, request.BudgetId!.Value, (List)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)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> 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> 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 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 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, 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; 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 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 BuildProfitAndLossReportAsync(DateOnly periodStart, DateOnly periodEnd, bool includeClosing = false) { 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 // 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 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 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(); 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> 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 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 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 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); } } }