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:
Chris Chen
2026-06-24 19:26:59 -07:00
parent 1fa36ae62f
commit a5de2dbbb1
4 changed files with 171 additions and 0 deletions
@@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form990ReportServiceTests
{
private static AppDbContext BuildDb()
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static async Task SeedAsync(AppDbContext db)
{
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries", SortOrder = 5 });
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 24, LineCode = "24", Name_en = "Other", SortOrder = 21 });
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "ManagementGeneral" });
db.Ministries.Add(new Ministry { Id = 2, Name_en = "Worship", DefaultFunctionalClass = "Program" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel", Form990LineId = 24 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Salary", Form990LineId = 7 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 1, Name_en = "Misc", Form990LineId = null });
await db.SaveChangesAsync();
}
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
{
MinistryId = min, CategoryGroupId = 1, SubCategoryId = sub, Type = "VendorPayment",
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
FunctionalClass = fc,
};
[Fact]
public async Task Statement_AggregatesByLineAndFunction_WithFallbackAndUnmappedCount()
{
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
db.Expenses.Add(Exp(1, 1, 40m, "Approved"));
db.Expenses.Add(Exp(2, 2, 25m, "Paid"));
db.Expenses.Add(Exp(2, 1, 999m, "Draft"));
db.Expenses.Add(Exp(1, 1, 10m, "Paid", fc: "Program"));
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
var line7 = stmt.Rows.Single(r => r.LineCode == "7");
Assert.Equal(110m, line7.Program);
Assert.Equal(40m, line7.ManagementGeneral);
Assert.Equal(150m, line7.Total);
var line24 = stmt.Rows.Single(r => r.LineCode == "24");
Assert.Equal(25m, line24.Program);
Assert.Equal(1, stmt.UnmappedExpenseCount);
Assert.Equal(175m, stmt.GrandTotal);
Assert.Equal(135m, stmt.ProgramTotal);
Assert.Equal(40m, stmt.ManagementGeneralTotal);
}
[Fact]
public async Task Statement_RespectsDateRange()
{
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
var older = Exp(2, 1, 500m, "Paid"); older.ExpenseDate = new DateOnly(2026, 1, 1);
db.Expenses.Add(older);
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
Assert.Equal(100m, stmt.GrandTotal);
}
}
+1
View File
@@ -155,6 +155,7 @@ builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
@@ -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);
}