Files

443 lines
26 KiB
C#

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<ApiResponse> GenerateAsync(ListReportsRequest request)
{
if (!Enum.TryParse<ReportType>(request.ReportType, ignoreCase: true, out var reportType))
{
throw new BadRequestException(
$"Invalid reportType '{request.ReportType}'. Must be one of: {string.Join(", ", Enum.GetNames<ReportType>())}.");
}
if (!Enum.TryParse<ReportOutputFormat>(request.OutputFormat, ignoreCase: true, out var outputFormat))
{
throw new BadRequestException(
$"Invalid outputFormat '{request.OutputFormat}'. Must be one of: {string.Join(", ", Enum.GetNames<ReportOutputFormat>())}.");
}
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<TrialBalanceRowDto>)data),
ReportType.BalanceSheet => BalanceSheetCsvDocument.Generate((BalanceSheetReportDto)data),
ReportType.ProfitAndLoss => ProfitAndLossCsvDocument.Generate((ProfitAndLossReportDto)data),
ReportType.CashFlow => CashFlowCsvDocument.Generate((CashFlowReportDto)data),
ReportType.GeneralLedger => GeneralLedgerCsvDocument.Generate((List<GeneralLedgerLineDto>)data),
ReportType.BudgetVsActual => BudgetVsActualCsvDocument.Generate((List<BudgetVsActualRowDto>)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<byte[]> 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<TrialBalanceRowDto>)data),
ReportType.BudgetVsActual => new BudgetVsActualPdfDocument(companyName, currencyCode, request.BudgetId!.Value, (List<BudgetVsActualRowDto>)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<GeneralLedgerLineDto>)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<List<GeneralLedgerLineDto>> 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<List<TrialBalanceRowDto>> 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<BalanceSheetReportDto> 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<BalanceSheetLeafRowDto> 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<ProfitAndLossReportDto> 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<ProfitAndLossReportDto> 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<ProfitAndLossLeafRowDto> 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<CashFlowReportDto> 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<CashFlowNonCashAdjustmentDto>();
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<List<BudgetVsActualRowDto>> 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<IncomeTaxComputationDto> 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<decimal> 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<decimal> 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);
}
}
}