diff --git a/API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs b/API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs new file mode 100644 index 0000000..618895e --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs @@ -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(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AppDbContext(new DbContextOptionsBuilder() + .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); + } +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 4c0ee6d..2bcd1f5 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -155,6 +155,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/API/ROLAC.API/Services/Form990ReportService.cs b/API/ROLAC.API/Services/Form990ReportService.cs new file mode 100644 index 0000000..845fbb2 --- /dev/null +++ b/API/ROLAC.API/Services/Form990ReportService.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Finance; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +/// +/// 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. +/// +public class Form990ReportService : IForm990ReportService +{ + private readonly AppDbContext _db; + public Form990ReportService(AppDbContext db) => _db = db; + + public async Task 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(); + 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; + } +} diff --git a/API/ROLAC.API/Services/IForm990ReportService.cs b/API/ROLAC.API/Services/IForm990ReportService.cs new file mode 100644 index 0000000..bc7961b --- /dev/null +++ b/API/ROLAC.API/Services/IForm990ReportService.cs @@ -0,0 +1,7 @@ +using ROLAC.API.DTOs.Finance; +namespace ROLAC.API.Services; + +public interface IForm990ReportService +{ + Task GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to); +}