feat(finance): Form 990 Part IX functional-expense aggregation service
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Finance;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
|
||||
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
|
||||
/// Single function per expense (direct-charge); no cost splitting.
|
||||
/// </summary>
|
||||
public class Form990ReportService : IForm990ReportService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public Form990ReportService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to)
|
||||
{
|
||||
var lines = await _db.Form990ExpenseLines.AsNoTracking()
|
||||
.Where(l => l.IsActive).OrderBy(l => l.SortOrder).ToListAsync();
|
||||
var fallbackId = lines.FirstOrDefault(l => l.LineCode == "24")?.Id;
|
||||
|
||||
var expenses = _db.Expenses.Where(e => e.Status == "Paid" || e.Status == "Approved");
|
||||
if (from.HasValue) expenses = expenses.Where(e => e.ExpenseDate >= from.Value);
|
||||
if (to.HasValue) expenses = expenses.Where(e => e.ExpenseDate <= to.Value);
|
||||
|
||||
var rows = await (
|
||||
from e in expenses
|
||||
join m in _db.Ministries on e.MinistryId equals m.Id
|
||||
join sub in _db.ExpenseSubCategories on e.SubCategoryId equals sub.Id
|
||||
join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.Id
|
||||
select new
|
||||
{
|
||||
e.Amount,
|
||||
e.FunctionalClass,
|
||||
MinistryDefault = m.DefaultFunctionalClass,
|
||||
SubLineId = sub.Form990LineId,
|
||||
GroupLineId = grp.Form990LineId,
|
||||
}).ToListAsync();
|
||||
|
||||
var acc = new Dictionary<int, (decimal P, decimal M, decimal F)>();
|
||||
var unmapped = 0;
|
||||
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var function = FunctionalClasses.Normalize(r.FunctionalClass ?? r.MinistryDefault);
|
||||
var lineId = r.SubLineId ?? r.GroupLineId ?? fallbackId;
|
||||
if (lineId is null) continue;
|
||||
|
||||
if (r.SubLineId is null) unmapped++;
|
||||
|
||||
var cur = acc.GetValueOrDefault(lineId.Value);
|
||||
acc[lineId.Value] = function switch
|
||||
{
|
||||
FunctionalClasses.ManagementGeneral => (cur.P, cur.M + r.Amount, cur.F),
|
||||
FunctionalClasses.Fundraising => (cur.P, cur.M, cur.F + r.Amount),
|
||||
_ => (cur.P + r.Amount, cur.M, cur.F),
|
||||
};
|
||||
}
|
||||
|
||||
var dto = new FunctionalExpenseStatementDto { UnmappedExpenseCount = unmapped };
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var v = acc.GetValueOrDefault(line.Id);
|
||||
dto.Rows.Add(new FunctionalExpenseRowDto
|
||||
{
|
||||
LineCode = line.LineCode, Name_en = line.Name_en, Name_zh = line.Name_zh,
|
||||
Program = v.P, ManagementGeneral = v.M, Fundraising = v.F, Total = v.P + v.M + v.F,
|
||||
});
|
||||
dto.ProgramTotal += v.P;
|
||||
dto.ManagementGeneralTotal += v.M;
|
||||
dto.FundraisingTotal += v.F;
|
||||
}
|
||||
dto.GrandTotal = dto.ProgramTotal + dto.ManagementGeneralTotal + dto.FundraisingTotal;
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using ROLAC.API.DTOs.Finance;
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IForm990ReportService
|
||||
{
|
||||
Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to);
|
||||
}
|
||||
Reference in New Issue
Block a user