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.
/// Each expense line is categorized independently, so one invoice can span multiple lines.
///
public class Form990ReportService : IForm990ReportService
{
private readonly AppDbContext _db;
public Form990ReportService(AppDbContext db) => _db = db;
public async Task> GetLinesAsync() =>
await _db.Form990ExpenseLines.AsNoTracking().Where(l => l.IsActive)
.OrderBy(l => l.SortOrder)
.Select(l => new Form990ExpenseLineDto
{
Id = l.Id,
LineCode = l.LineCode,
Name_en = l.Name_en,
Name_zh = l.Name_zh,
SortOrder = l.SortOrder,
})
.ToListAsync();
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 l in _db.ExpenseLines on e.Id equals l.ExpenseId
join m in _db.Ministries on e.MinistryId equals m.Id
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
select new
{
l.Amount,
l.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;
}
}