diff --git a/API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs b/API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs new file mode 100644 index 0000000..90e68a1 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using System.Security.Claims; +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 Form1099ReportServiceTests +{ + private static AppDbContext NewDb() + { + var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) }; + var accessorMock = new Mock(); + accessorMock.Setup(x => x.HttpContext).Returns(httpContext); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessorMock.Object))).Options); + } + + private static AppDbContext Seeded(out int necSubId, out int rentSubId, out int salarySubId) + { + var db = NewDb(); + db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "Program" }); + var nec = new Form1099Box { Id = 1, BoxCode = Form1099.BoxNec1, Name_en = "NEC", FormType = "1099-NEC", SortOrder = 1 }; + var rent = new Form1099Box { Id = 2, BoxCode = Form1099.BoxMisc1, Name_en = "Rent", FormType = "1099-MISC", SortOrder = 2 }; + db.Form1099Boxes.AddRange(nec, rent); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel" }); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Facility" }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Contract Labor", Form1099BoxId = 1 }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Rent", Form1099BoxId = 2 }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 3, GroupId = 1, Name_en = "Salary & Wages", Form1099BoxId = null }); + db.SaveChanges(); + necSubId = 1; rentSubId = 2; salarySubId = 3; + return db; + } + + private static void AddPaidExpense(AppDbContext db, int payeeId, int subId, int groupId, decimal amount, DateOnly paidOn) + { + var e = new Expense + { + MinistryId = 1, Type = "VendorPayment", Status = "Paid", PayeeId = payeeId, + Amount = amount, Description = "x", ExpenseDate = paidOn, + PaidAt = new DateTimeOffset(paidOn.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), + Lines = [ new ExpenseLine { CategoryGroupId = groupId, SubCategoryId = subId, Amount = amount } ], + }; + db.Expenses.Add(e); + db.SaveChanges(); + } + + [Fact] + public async Task Sums_tracked_recipient_by_box_and_flags_threshold_and_w9() + { + var db = Seeded(out var necSub, out var rentSub, out _); + db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Pat Player", Is1099Tracked = true, W9Status = "Missing" }); + db.SaveChanges(); + AddPaidExpense(db, 10, necSub, 1, 700m, new DateOnly(2026, 3, 1)); + AddPaidExpense(db, 10, rentSub, 2, 500m, new DateOnly(2026, 4, 1)); + + var svc = new Form1099ReportService(db); + var sum = await svc.GetAnnualSummaryAsync(2026); + + var row = Assert.Single(sum.Rows); + Assert.Equal(700m, row.NecTotal); + Assert.Equal(500m, row.RentsTotal); + Assert.Equal(1200m, row.GrandTotal); + Assert.True(row.MeetsThreshold); + Assert.True(row.W9Missing); + Assert.Equal(1, sum.RecipientsAtThreshold); + Assert.Equal(1, sum.RecipientsMissingW9); + } + + [Fact] + public async Task Excludes_untracked_recipients_and_unmapped_and_wrong_year() + { + var db = Seeded(out var necSub, out _, out var salarySub); + db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Tracked Tim", Is1099Tracked = true, W9Status = "OnFile" }); + db.Payee1099s.Add(new Payee1099 { Id = 11, LegalName = "Corp Inc", Is1099Tracked = false, W9Status = "OnFile" }); + db.SaveChanges(); + AddPaidExpense(db, 11, necSub, 1, 5000m, new DateOnly(2026, 5, 1)); // untracked + AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box + AddPaidExpense(db, 10, necSub, 1, 5000m, new DateOnly(2025, 6, 1)); // wrong year + + var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026); + Assert.Empty(sum.Rows); + } + + [Fact] + public async Task Threshold_flag_is_false_below_600() + { + var db = Seeded(out var necSub, out _, out _); + db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Small Sam", Is1099Tracked = true, W9Status = "OnFile" }); + db.SaveChanges(); + AddPaidExpense(db, 10, necSub, 1, 599.99m, new DateOnly(2026, 7, 1)); + + var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026); + var row = Assert.Single(sum.Rows); + Assert.False(row.MeetsThreshold); + Assert.False(row.W9Missing); + Assert.Equal(0, sum.RecipientsAtThreshold); + } +} diff --git a/API/ROLAC.API/Services/Form1099ReportService.cs b/API/ROLAC.API/Services/Form1099ReportService.cs new file mode 100644 index 0000000..51d3e4d --- /dev/null +++ b/API/ROLAC.API/Services/Form1099ReportService.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Finance; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +/// +/// Read-only aggregation producing the year-end 1099 recipient summary. CASH BASIS: +/// only Paid expenses whose PaidAt falls in the tax year, attributed to a tracked payee, +/// on a line whose category maps to a 1099 box (sub ?? group). Unmapped lines are excluded. +/// +public class Form1099ReportService : IForm1099ReportService +{ + private readonly AppDbContext _db; + public Form1099ReportService(AppDbContext db) => _db = db; + + public async Task> GetBoxesAsync() => + await _db.Form1099Boxes.AsNoTracking().Where(b => b.IsActive) + .OrderBy(b => b.SortOrder) + .Select(b => new Form1099BoxDto + { + Id = b.Id, BoxCode = b.BoxCode, Name_en = b.Name_en, + Name_zh = b.Name_zh, FormType = b.FormType, SortOrder = b.SortOrder, + }).ToListAsync(); + + private IQueryable ReportableLines(int taxYear) => + from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null + && e.PaidAt.Value.Year == taxYear && e.PayeeId != null) + join p in _db.Payee1099s.Where(p => p.Is1099Tracked) on e.PayeeId equals p.Id + join l in _db.ExpenseLines on e.Id equals l.ExpenseId + join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id + join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id + select new PaidLine + { + PayeeId = p.Id, + LegalName = p.LegalName, + TinLast4 = p.TinLast4, + W9Status = p.W9Status, + PaidAt = e.PaidAt!.Value, + Description = e.Description, + CategoryName = grp.Name_en + " / " + sub.Name_en, + Amount = l.Amount, + BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId, + }; + + public async Task GetAnnualSummaryAsync(int taxYear) + { + var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode); + var lines = await ReportableLines(taxYear).Where(x => x.BoxId != null).ToListAsync(); + + var dto = new Form1099SummaryDto { TaxYear = taxYear }; + foreach (var g in lines.GroupBy(x => x.PayeeId)) + { + var first = g.First(); + var nec = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxNec1).Sum(x => x.Amount); + var rents = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxMisc1).Sum(x => x.Amount); + var w9Missing = first.W9Status != Form1099.W9Status.OnFile; + var meets = nec >= Form1099.ReportingThreshold || rents >= Form1099.ReportingThreshold; + dto.Rows.Add(new Form1099RecipientRowDto + { + PayeeId = first.PayeeId, LegalName = first.LegalName, TinLast4 = first.TinLast4, + W9Status = first.W9Status, NecTotal = nec, RentsTotal = rents, + GrandTotal = nec + rents, MeetsThreshold = meets, W9Missing = w9Missing, + }); + } + dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ToList(); + dto.TotalReportable = dto.Rows.Sum(r => r.GrandTotal); + dto.RecipientsAtThreshold = dto.Rows.Count(r => r.MeetsThreshold); + dto.RecipientsMissingW9 = dto.Rows.Count(r => r.W9Missing); + return dto; + } + + public async Task GetRecipientDetailAsync(int payeeId, int taxYear) + { + var payee = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(p => p.Id == payeeId); + if (payee is null) return null; + var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode); + var lines = await ReportableLines(taxYear).Where(x => x.PayeeId == payeeId && x.BoxId != null).ToListAsync(); + + return new Form1099RecipientDetailDto + { + PayeeId = payee.Id, LegalName = payee.LegalName, TinLast4 = payee.TinLast4, + W9Status = payee.W9Status, TaxYear = taxYear, + Payments = lines.OrderBy(x => x.PaidAt).Select(x => new Form1099PaymentDto + { + PaidDate = DateOnly.FromDateTime(x.PaidAt.Date).ToString("yyyy-MM-dd"), + Description = x.Description, CategoryName = x.CategoryName, + BoxCode = boxes.GetValueOrDefault(x.BoxId!.Value) ?? "", Amount = x.Amount, + }).ToList(), + }; + } + + private sealed class PaidLine + { + public int PayeeId; public string LegalName = ""; public string? TinLast4; public string W9Status = ""; + public DateTimeOffset PaidAt; public string Description = ""; public string CategoryName = ""; + public decimal Amount; public int? BoxId; + } +} diff --git a/API/ROLAC.API/Services/IForm1099ReportService.cs b/API/ROLAC.API/Services/IForm1099ReportService.cs new file mode 100644 index 0000000..64bf88d --- /dev/null +++ b/API/ROLAC.API/Services/IForm1099ReportService.cs @@ -0,0 +1,9 @@ +using ROLAC.API.DTOs.Finance; +namespace ROLAC.API.Services; + +public interface IForm1099ReportService +{ + Task> GetBoxesAsync(); + Task GetAnnualSummaryAsync(int taxYear); + Task GetRecipientDetailAsync(int payeeId, int taxYear); +}