438 lines
26 KiB
C#
438 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);
|
|
equity.Lines.Add(new BalanceSheetLineDto { AccountCode = "", AccountName = "Current Year Earnings", 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)
|
|
{
|
|
var leafRows = await _accountRepository.GetProfitAndLossLeafRowsAsync(periodStart, periodEnd);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|